Generate share passwords and offer them again beside the new link

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>
This commit is contained in:
Andreas Reinhold / reini
2026-09-16 11:00:11 +02:00
co-authored by Claude Opus 5
parent a88a052d9a
commit 504971ad7f
20 changed files with 8672 additions and 6 deletions
+1
View File
@@ -8,4 +8,5 @@ Before planning or editing, find the row whose globs match the file's path and r
| resources/css/material-scheme.* | .ai/rules/css.md |
| resources/views/livewire/share-download.blade.php | .ai/rules/livewire.md |
| tests/Screenshots/** | .ai/rules/screenshots.md |
| resources/views/** | .ai/rules/views.md |
| website/** | .ai/rules/website.md |
+9
View File
@@ -0,0 +1,9 @@
---
paths:
- 'resources/views/**'
---
# Views
## `<x-group>` drops data-test and other attributes
`<x-group>` (Livewire Material) keeps only class, style and wire:key on its fieldset and wire:model/x-model on its inputs; data-test, id and every other attribute are silently dropped. Tests reach a group through its binding instead, e.g. assertSeeHtml('wire:model.live="passwordGeneratorType"') or input[value="…"]. Rendering `<x-group>` also needs components/group.css imported in resources/css/app.css (DesignLanguageTest's missingStylesheets guards it).
+9
View File
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [2.1.0] - Unreleased
### Added
- A password generator for share passwords. With "Password protect" on, the upload page offers Generate and Copy under the password field. On the next page the password is offered once more beside the link — masked, with a copy button at the end of the field like the link's. It is passed along encrypted in the session and never stored.
- A "Share Passwords" card in Admin settings controls the generator:
- Mode: off, on request (the Generate button) or prefilled as soon as protection is switched on.
- Kind: random characters (length 1264; uppercase, lowercase, numbers and symbols; look-alike characters left out if chosen) or a passphrase (410 words from EFF's large word list, with a chosen separator).
- An example with its estimated entropy shows before saving.
- Defaults: on request, 20 letters and numbers without look-alikes.
### Changed
- The interface moves to [Livewire Material](https://gitea.nonameweb.ch/noNameWEB/livewire-material) 2.0.0, which aligns every component with Material 3 Expressive as Google documents it. SealShare keeps the pages, the arrangement and the flow it had — rebuilt on the new components — and no longer ships Tailwind CSS.
+3 -1
View File
@@ -19,7 +19,7 @@ A simple, self-hosted file sharing solution built with Laravel. Upload files, ge
- **File Uploading** — Drag & drop or browse to upload single/multiple files and folders with real-time progress
- **Shareable Links** — Each upload generates a unique link for recipients, also as a QR code (saved as a PNG) or through the device's share sheet
- **Encryption at Rest** — Files are encrypted on the server as they arrive, with AES-256-GCM (chunked, streaming); with a share password the key is derived from it and never stored. It is not end-to-end encryption: the server handles the files unencrypted while they are uploaded and downloaded
- **Password Protection** — Optionally protect shares with a password
- **Password Protection** — Optionally protect shares with a password, typed or generated (random characters or a passphrase, as the admin configures) and copied on the upload page or next to the new link
- **Expiration** — Shares auto-expire after a configurable duration (1 hour to 30 days)
- **Download Limits** — Set a maximum number of downloads per share
- **ZIP Downloads** — Download all files in a share as a single ZIP archive
@@ -152,3 +152,5 @@ Add the scheduler to your crontab:
## License
This project is open-source software licensed under the [MIT License](LICENSE).
Generated passphrases draw from the [EFF Large Wordlist](https://www.eff.org/deeplinks/2016/07/new-wordlists-random-passphrases) by the Electronic Frontier Foundation, licensed under [CC BY 3.0 US](https://creativecommons.org/licenses/by/3.0/us/) (`resources/wordlists/eff-large-wordlist.txt`, without its four hyphenated words).
+119 -1
View File
@@ -3,8 +3,10 @@
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;
@@ -35,6 +37,23 @@ class AdminSettings extends Component
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 = '';
@@ -61,6 +80,15 @@ class AdminSettings extends Component
$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 static function phpMaxUploadMb(): int
@@ -88,7 +116,7 @@ class AdminSettings extends Component
{
$phpMaxMb = self::phpMaxUploadMb();
$this->validate([
$validated = $this->validate([
'colorProfile' => ['required', 'string', Rule::in(array_keys(Scheme::profiles()))],
'maxFileSize' => ['required', 'integer', 'min:1', 'max:'.$phpMaxMb],
'maxStorageQuota' => ['required', 'integer', 'min:1'],
@@ -97,8 +125,10 @@ class AdminSettings extends Component
'siteTitle' => ['nullable', 'string', 'max:255'],
'siteDescription' => ['nullable', 'string', 'max:1000'],
'siteLogo' => ['nullable', 'file', 'mimes:png,jpg,jpeg,gif,webp', 'max:2048'],
...$this->passwordGeneratorRules(),
], [
'maxFileSize.max' => __('Cannot exceed the PHP limit of :max MB. Increase upload_max_filesize and post_max_size in your PHP configuration.', ['max' => $phpMaxMb]),
...$this->passwordGeneratorMessages(),
]);
if ($this->systemPassword) {
@@ -116,6 +146,8 @@ class AdminSettings extends Component
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) {
@@ -132,6 +164,87 @@ class AdminSettings extends Component
$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');
@@ -157,10 +270,15 @@ class AdminSettings extends Component
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'),
'phpMaxUploadMb' => self::phpMaxUploadMb(),
'passwordExample' => $passwordPreviewOptions ? $passwordGenerator->generate($passwordPreviewOptions) : null,
'passwordEntropy' => $passwordPreviewOptions ? $passwordGenerator->entropyBits($passwordPreviewOptions) : null,
]);
}
}
+35
View File
@@ -3,7 +3,9 @@
namespace App\Livewire;
use App\Models\Setting;
use App\Services\PasswordGeneratorService;
use App\Services\ShareService;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
@@ -101,6 +103,29 @@ class FileUploader extends Component
}
}
/**
* Fill in a generated password as protection is switched on, when the admin chose "Prefilled".
* A password already in the field stays.
*/
public function updatedUsePassword(bool $value): void
{
$passwordGenerator = app(PasswordGeneratorService::class);
if ($value && $this->password === '' && $passwordGenerator->mode() === 'prefill') {
$this->password = $passwordGenerator->generate();
}
}
public function generatePassword(PasswordGeneratorService $passwordGenerator): void
{
if ($passwordGenerator->mode() === 'off') {
return;
}
$this->password = $passwordGenerator->generate();
$this->resetErrorBag('password');
}
public function removeFile(int $index): void
{
unset($this->files[$index], $this->relativePaths[$index]);
@@ -184,6 +209,15 @@ class FileUploader extends Component
'max_downloads' => $this->maxDownloads ?: null,
]);
// The page the upload leads to offers the password once more, next to the link; it is
// never stored in the clear, so this flash is the only way it gets there.
if ($this->usePassword) {
session()->flash('share_password', [
'token' => $share->token,
'password' => Crypt::encryptString($this->password),
]);
}
$this->redirect(route('share.created', $share), navigate: true);
}
@@ -197,6 +231,7 @@ class FileUploader extends Component
'siteDescription' => Setting::get('site_description'),
'siteLogo' => Setting::get('site_logo'),
'allowNeverExpire' => (bool) Setting::get('allow_never_expire', false),
'passwordGeneratorMode' => app(PasswordGeneratorService::class)->mode(),
]);
}
}
+12
View File
@@ -5,7 +5,9 @@ namespace App\Livewire;
use App\Models\Setting;
use App\Models\Share;
use App\Services\QrCodeService;
use Illuminate\Support\Facades\Crypt;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Component;
#[Layout('layouts.app')]
@@ -13,9 +15,19 @@ class ShareCreated extends Component
{
public Share $share;
/** The share's password, offered once to the uploader who just set it; `null` on any other visit. */
#[Locked]
public ?string $password = null;
public function mount(Share $share): void
{
$this->share = $share;
$flashedPassword = session('share_password');
if (is_array($flashedPassword) && ($flashedPassword['token'] ?? null) === $share->token) {
$this->password = Crypt::decryptString($flashedPassword['password']);
}
}
public function render(): mixed
+197
View File
@@ -0,0 +1,197 @@
<?php
namespace App\Services;
use App\Models\Setting;
use InvalidArgumentException;
use Random\Randomizer;
/**
* Random share passwords, drawn the way Admin settings say.
*
* Every draw comes from `Random\Randomizer`'s default engine, which is the operating system's
* CSPRNG. Passphrases come from EFF's large word list (CC BY 3.0 US), without its four hyphenated
* words so a separator always splits a passphrase into its words.
*/
class PasswordGeneratorService
{
/** Off: uploaders type their own. Button: a Generate button fills one in. Prefill: filled in as protection is switched on. */
public const MODES = ['off', 'button', 'prefill'];
public const TYPES = ['characters', 'passphrase'];
/**
* The characters each set draws from. The symbols leave out what chat apps turn into formatting
* (`* _ ~ \``) and what breaks once pasted into quotes or markup (`' " \ < >`).
*
* @var array<string, string>
*/
public const CHARACTER_SETS = [
'uppercase' => 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
'lowercase' => 'abcdefghijklmnopqrstuvwxyz',
'numbers' => '0123456789',
'symbols' => '!#$%&()+,-./:;=?@[]{}',
];
/** Characters that read alike in many typefaces. */
public const AMBIGUOUS_CHARACTERS = '0O1lI';
/** @var array<string, string> */
public const SEPARATORS = [
'hyphen' => '-',
'dot' => '.',
'underscore' => '_',
'space' => ' ',
];
public const MIN_LENGTH = 12;
public const MAX_LENGTH = 64;
public const MIN_WORDS = 4;
public const MAX_WORDS = 10;
/**
* @var array{mode: string, type: string, length: int, characterSets: list<string>, avoidAmbiguous: bool, words: int, separator: string}
*/
public const DEFAULTS = [
'mode' => 'button',
'type' => 'characters',
'length' => 20,
'characterSets' => ['uppercase', 'lowercase', 'numbers'],
'avoidAmbiguous' => true,
'words' => 6,
'separator' => 'hyphen',
];
/** @var list<string>|null */
private ?array $wordList = null;
/**
* How the upload page offers generated passwords.
*/
public function mode(): string
{
$mode = Setting::get('password_generator_mode');
return in_array($mode, self::MODES, true) ? $mode : self::DEFAULTS['mode'];
}
/**
* The saved generator settings, with the default for anything missing or no longer allowed.
*
* @return array{mode: string, type: string, length: int, characterSets: list<string>, avoidAmbiguous: bool, words: int, separator: string}
*/
public function options(): array
{
$type = Setting::get('password_generator_type');
$length = (int) Setting::get('password_generator_length', self::DEFAULTS['length']);
$words = (int) Setting::get('password_generator_words', self::DEFAULTS['words']);
$separator = Setting::get('password_generator_separator');
$characterSets = array_values(array_intersect(
array_keys(self::CHARACTER_SETS),
explode(',', (string) Setting::get('password_generator_character_sets')),
));
return [
'mode' => $this->mode(),
'type' => in_array($type, self::TYPES, true) ? $type : self::DEFAULTS['type'],
'length' => $length >= self::MIN_LENGTH && $length <= self::MAX_LENGTH ? $length : self::DEFAULTS['length'],
'characterSets' => $characterSets ?: self::DEFAULTS['characterSets'],
'avoidAmbiguous' => (bool) Setting::get('password_generator_avoid_ambiguous', self::DEFAULTS['avoidAmbiguous'] ? '1' : '0'),
'words' => $words >= self::MIN_WORDS && $words <= self::MAX_WORDS ? $words : self::DEFAULTS['words'],
'separator' => is_string($separator) && array_key_exists($separator, self::SEPARATORS) ? $separator : self::DEFAULTS['separator'],
];
}
/**
* Generate a password from the given options, or from the saved settings.
*
* @param array{type: string, length: int, characterSets: list<string>, avoidAmbiguous: bool, words: int, separator: string}|null $options
*/
public function generate(?array $options = null): string
{
$options ??= $this->options();
return $options['type'] === 'passphrase'
? $this->passphrase($options['words'], self::SEPARATORS[$options['separator']])
: $this->characters($options['length'], $options['characterSets'], $options['avoidAmbiguous']);
}
/**
* Draw characters uniformly from the chosen sets, drawing again until every set shows up at
* least once. Redrawing keeps each valid password equally likely, where placing one character
* of each set first would not.
*
* @param list<string> $characterSets
*/
public function characters(int $length, array $characterSets, bool $avoidAmbiguous): string
{
$alphabets = $this->alphabets($characterSets, $avoidAmbiguous);
if ($alphabets === [] || $length < count($alphabets)) {
throw new InvalidArgumentException('A password needs at least one character set and room for each of them.');
}
$randomizer = new Randomizer;
do {
$password = $randomizer->getBytesFromString(implode('', $alphabets), $length);
} while (array_filter($alphabets, fn (string $alphabet): bool => strpbrk($password, $alphabet) === false) !== []);
return $password;
}
/**
* Draw words from the word list, each independently of the others.
*/
public function passphrase(int $words, string $separator): string
{
$wordList = $this->wordList();
$randomizer = new Randomizer;
return implode($separator, array_map(
fn (): string => $wordList[$randomizer->getInt(0, count($wordList) - 1)],
range(1, max(1, $words)),
));
}
/**
* Roughly how many bits of entropy a password from these options carries.
*
* @param array{type: string, length: int, characterSets: list<string>, avoidAmbiguous: bool, words: int} $options
*/
public function entropyBits(array $options): int
{
if ($options['type'] === 'passphrase') {
return (int) floor($options['words'] * log(count($this->wordList()), 2));
}
$alphabetSize = strlen(implode('', $this->alphabets($options['characterSets'], $options['avoidAmbiguous'])));
return $alphabetSize > 0 ? (int) floor($options['length'] * log($alphabetSize, 2)) : 0;
}
/**
* @return list<string>
*/
public function wordList(): array
{
return $this->wordList ??= file(resource_path('wordlists/eff-large-wordlist.txt'), FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
}
/**
* The characters of each chosen set, without the look-alikes when asked.
*
* @param list<string> $characterSets
* @return array<string, string>
*/
private function alphabets(array $characterSets, bool $avoidAmbiguous): array
{
return collect(self::CHARACTER_SETS)
->only($characterSets)
->map(fn (string $alphabet): string => $avoidAmbiguous ? str_replace(str_split(self::AMBIGUOUS_CHARACTERS), '', $alphabet) : $alphabet)
->all();
}
}
+1
View File
@@ -16,6 +16,7 @@
@import '../../vendor/nonameweb/livewire-material/resources/css/components/empty-state.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/file.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/form.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/group.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/icon.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/input.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/list-item.css';
@@ -56,6 +56,101 @@
</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' => 'AZ'],
['id' => 'lowercase', 'name' => 'az'],
['id' => 'numbers', 'name' => '09'],
['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
@@ -163,7 +163,26 @@
<x-toggle wire:model.live="usePassword" :label="__('Password protect')" right />
@if ($usePassword)
<x-stack gap="space100">
<x-password full wire:model="password" :label="__('Password')" autocomplete="new-password" />
{{-- Generate draws one as Admin settings say (App\Services\PasswordGeneratorService); Copy takes
whatever is in the field, typed or generated, with the snackbar a copyable field shows. --}}
<x-row gap="space100" wrap>
@if ($passwordGeneratorMode !== 'off')
<x-button :label="__('Generate')" icon="password" variant="tonal" wire:click="generatePassword" spinner="generatePassword" data-test="generate-password" />
@endif
<x-button
:label="__('Copy')"
icon="content_copy"
variant="tonal"
x-on:click="navigator.clipboard.writeText($wire.password).then(() => window.materialToast({{ \Illuminate\Support\Js::from(__('Copied to the clipboard')) }}, { type: 'success' }))"
x-bind:disabled="! $wire.password"
data-test="copy-password"
/>
</x-row>
</x-stack>
@endif
<x-select full
@@ -36,6 +36,22 @@
data-test="share-link"
/>
{{-- Only on the visit the upload redirects to: the password is flashed once (FileUploader::createShare).
Masked, with the link's copy button at its end, so it is copied without reaching the screen. --}}
@if ($password)
<x-input
type="password"
:label="__('Password')"
:value="$password"
:hint="__('Available only this once. Send it separately from the link.')"
readonly
copyable
icon="key"
autocomplete="off"
data-test="share-password"
/>
@endif
<x-row gap="space100" wrap>
<x-button :label="__('Show QR code')" icon="qr_code_2" variant="tonal" x-on:click="open = true" data-test="show-qr-code" />
File diff suppressed because it is too large Load Diff
+38
View File
@@ -5,6 +5,7 @@ use App\Models\Share;
use App\Models\User;
use App\Services\ShareService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Storage;
use NoNameWeb\LivewireMaterial\Support\Scheme;
@@ -47,6 +48,43 @@ test('a new share\'s link can be copied from the page the upload leads to', func
->assertSee('Copied to the clipboard');
});
test('an uploader generates a share password and copies it from the upload page', function () {
$page = ready(visit('/upload'));
$page->click('label:has-text("Password protect")')
->click('[data-test="generate-password"]')
->assertScript("/^[A-Za-z0-9]{20}$/.test(document.querySelector('input[wire\\\\:model=\"password\"]').value)");
$page->script("window.eval(\"Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (text) => { window.copied = text } } })\")");
$page->click('[data-test="copy-password"]')
->assertScript("window.copied === document.querySelector('input[wire\\\\:model=\"password\"]').value")
->assertSee('Copied to the clipboard')
->assertNoJavaScriptErrors();
});
test('the new share\'s password stays masked on the page and is copied without being shown', function () {
$share = app(ShareService::class)->createShare(
[['file' => UploadedFile::fake()->create('contract.pdf', 80), 'relativePath' => null]],
['password' => 'violet-orbit-canyon'],
);
$this->withSession(['share_password' => ['token' => $share->token, 'password' => Crypt::encryptString('violet-orbit-canyon')]]);
$page = ready(visit(route('share.created', $share, false)));
$field = "document.querySelector('[data-test=share-password]')";
$page->assertScript("{$field}.type === 'password'")
->assertScript("{$field}.value === 'violet-orbit-canyon'");
$page->script("window.eval(\"Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (text) => { window.copied = text } } })\")");
$page->click('[data-md-field]:has([data-test="share-password"]) [data-md-field-copy]')
->assertScript("window.copied === 'violet-orbit-canyon'")
->assertScript("{$field}.type === 'password'")
->assertSee('Copied to the clipboard')
->assertNoJavaScriptErrors();
});
test('a new share\'s QR code opens in a dialog and saves as a PNG', function () {
$share = Share::factory()->withPassword()->create();
+2 -2
View File
@@ -173,7 +173,7 @@ test('the admin dashboard heads its shares table with an h2 and drops the card',
expect($result['noCard'])->toBeTrue();
});
test('the admin settings page has five headed sections and no card', function () {
test('the admin settings page has six headed sections and no card', function () {
$this->actingAs(User::factory()->admin()->create());
$page = ready(visit('/admin/settings'));
@@ -187,6 +187,6 @@ test('the admin settings page has five headed sections and no card', function ()
};
})()");
expect($result['h2Count'])->toBe(5);
expect($result['h2Count'])->toBe(6);
expect($result['noCard'])->toBeTrue();
});
+104
View File
@@ -157,3 +157,107 @@ test('a colour profile that was not generated is refused', function () {
expect(Setting::get('color_profile'))->toBeNull();
});
test('admin saves a character password generator, starting from the saved settings', function () {
$admin = User::query()->where('is_admin', true)->first();
Livewire::actingAs($admin)
->test(AdminSettings::class)
->assertSet('passwordGeneratorMode', 'button')
->set('passwordGeneratorMode', 'prefill')
->set('passwordGeneratorType', 'characters')
->set('passwordLength', 24)
->set('passwordCharacterSets', ['numbers', 'symbols'])
->set('passwordAvoidAmbiguous', false)
->call('saveSettings')
->assertHasNoErrors();
expect(Setting::get('password_generator_mode'))->toBe('prefill');
expect(Setting::get('password_generator_type'))->toBe('characters');
expect(Setting::get('password_generator_length'))->toBe('24');
expect(Setting::get('password_generator_character_sets'))->toBe('numbers,symbols');
expect(Setting::get('password_generator_avoid_ambiguous'))->toBe('0');
Livewire::actingAs($admin)
->test(AdminSettings::class)
->assertSet('passwordGeneratorMode', 'prefill')
->assertSet('passwordLength', 24)
->assertSet('passwordCharacterSets', ['numbers', 'symbols'])
->assertSet('passwordAvoidAmbiguous', false);
});
test('a passphrase generator saves its words and separator and ignores the hidden character fields', function () {
$admin = User::query()->where('is_admin', true)->first();
Livewire::actingAs($admin)
->test(AdminSettings::class)
->set('passwordGeneratorType', 'passphrase')
->set('passphraseWords', 8)
->set('passphraseSeparator', 'space')
->set('passwordLength', 3)
->set('passwordCharacterSets', [])
->call('saveSettings')
->assertHasNoErrors();
expect(Setting::get('password_generator_type'))->toBe('passphrase');
expect(Setting::get('password_generator_words'))->toBe('8');
expect(Setting::get('password_generator_separator'))->toBe('space');
expect(Setting::get('password_generator_length'))->toBeNull();
expect(Setting::get('password_generator_character_sets'))->toBeNull();
});
test('a password generator without any kind of character is refused', function () {
$admin = User::query()->where('is_admin', true)->first();
Livewire::actingAs($admin)
->test(AdminSettings::class)
->set('passwordCharacterSets', [])
->call('saveSettings')
->assertHasErrors(['passwordCharacterSets' => 'Choose at least one kind of character.']);
expect(Setting::get('password_generator_character_sets'))->toBeNull();
});
test('password generator settings out of range are refused', function (string $property, mixed $value, string $rule) {
$admin = User::query()->where('is_admin', true)->first();
Livewire::actingAs($admin)
->test(AdminSettings::class)
->set('passwordGeneratorType', $property === 'passphraseWords' ? 'passphrase' : 'characters')
->set($property, $value)
->call('saveSettings')
->assertHasErrors([$property => $rule]);
expect(Setting::get('password_generator_mode'))->toBeNull();
})->with([
'an unknown mode' => ['passwordGeneratorMode', 'sometimes', 'in'],
'a length below 12' => ['passwordLength', 8, 'min'],
'a length above 64' => ['passwordLength', 65, 'max'],
'fewer than 4 words' => ['passphraseWords', 3, 'min'],
]);
test('the password example follows the unsaved form and disappears while the form is invalid', function () {
$admin = User::query()->where('is_admin', true)->first();
Livewire::actingAs($admin)
->test(AdminSettings::class)
->set('passwordGeneratorType', 'passphrase')
->set('passphraseWords', 5)
->set('passphraseSeparator', 'dot')
->assertViewHas('passwordExample', fn (string $example): bool => count(explode('.', $example)) === 5)
->assertViewHas('passwordEntropy', 64)
->set('passphraseWords', 2)
->assertViewHas('passwordExample', null)
->assertDontSeeHtml('data-test="password-example"');
});
test('switching the password generator off hides its options', function () {
$admin = User::query()->where('is_admin', true)->first();
Livewire::actingAs($admin)
->test(AdminSettings::class)
->assertSeeHtml('wire:model.live="passwordGeneratorType"')
->set('passwordGeneratorMode', 'off')
->assertDontSeeHtml('wire:model.live="passwordGeneratorType"')
->assertDontSeeHtml('data-test="password-example"');
});
+68
View File
@@ -4,7 +4,9 @@ use App\Livewire\FileUploader;
use App\Livewire\SystemPasswordPrompt;
use App\Models\Setting;
use App\Models\Share;
use App\Services\ShareService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
@@ -65,6 +67,72 @@ test('file upload with password creates password-protected share', function () {
expect($share->isPasswordProtected())->toBeTrue();
});
test('switching password protection on leaves the field empty and offers a Generate button by default', function () {
$component = Livewire::test(FileUploader::class)
->set('usePassword', true);
$component->assertSet('password', '')
->assertSeeHtml('data-test="generate-password"')
->assertSeeHtml('data-test="copy-password"');
});
test('a generated password protects the share and is flashed, encrypted, for the page the upload leads to', function () {
Storage::fake('shares');
$component = Livewire::test(FileUploader::class)
->set('files', [UploadedFile::fake()->create('secret.txt', 512)])
->set('usePassword', true)
->call('generatePassword');
$password = $component->get('password');
$component->call('createShare');
$share = Share::query()->first();
expect($password)->toMatch('/^[A-Za-z0-9]{20}$/');
expect(app(ShareService::class)->verifyPassword($share, $password))->toBeTrue();
expect(session('share_password.token'))->toBe($share->token);
expect(Crypt::decryptString(session('share_password.password')))->toBe($password);
});
test('a share without a password flashes no password', function () {
Storage::fake('shares');
Livewire::test(FileUploader::class)
->set('files', [UploadedFile::fake()->create('document.pdf', 100)])
->call('createShare')
->assertRedirectContains('/share/');
expect(session()->has('share_password'))->toBeFalse();
});
test('a prefilling generator fills in a password as protection is switched on', function () {
Setting::set('password_generator_mode', 'prefill');
$component = Livewire::test(FileUploader::class)
->set('usePassword', true);
expect($component->get('password'))->toMatch('/^[A-Za-z0-9]{20}$/');
});
test('a prefilling generator keeps a password the uploader already typed', function () {
Setting::set('password_generator_mode', 'prefill');
Livewire::test(FileUploader::class)
->set('password', 'my-own-password')
->set('usePassword', true)
->assertSet('password', 'my-own-password');
});
test('a generator switched off offers no Generate button and generates nothing', function () {
Setting::set('password_generator_mode', 'off');
Livewire::test(FileUploader::class)
->set('usePassword', true)
->assertDontSeeHtml('data-test="generate-password"')
->assertSeeHtml('data-test="copy-password"')
->call('generatePassword')
->assertSet('password', '');
});
test('file upload with expiration sets expires_at', function () {
Storage::fake('shares');
+30
View File
@@ -2,6 +2,7 @@
use App\Models\Share;
use App\Services\QrCodeService;
use Illuminate\Support\Facades\Crypt;
test('the share created page offers the link as a QR code and through the share sheet', function () {
$share = Share::factory()->create();
@@ -30,3 +31,32 @@ test('the QR code dialog reminds that a protected share also needs its password'
->assertOk()
->assertSee('Recipients also need the password.');
});
test('the uploader who just set the password can copy it beside the link, without it being on screen', function () {
$share = Share::factory()->withPassword()->create();
$response = $this->withSession(['share_password' => ['token' => $share->token, 'password' => Crypt::encryptString('violet-orbit-canyon')]])
->get(route('share.created', $share));
$response->assertSee('data-test="share-password"', false)
->assertSee('Available only this once. Send it separately from the link.');
// A masked field holding the password, with the field's copy button at its end.
expect($response->getContent())
->toMatch('#<input(?=[^>]*value="violet-orbit-canyon")(?=[^>]*type="password")(?=[^>]*data-test="share-password")[^>]*>#')
->toMatch('#data-test="share-password".*?data-md-field-copy#s');
});
test('the password is not shown without a flash for this share', function (?string $flashedFor) {
$share = Share::factory()->withPassword()->create();
$session = $flashedFor === null ? [] : ['share_password' => ['token' => $flashedFor, 'password' => Crypt::encryptString('violet-orbit-canyon')]];
$response = $this->withSession($session)->get(route('share.created', $share));
$response->assertOk()
->assertDontSee('data-test="share-password"', false)
->assertDontSee('violet-orbit-canyon');
})->with([
'no flash (a reload or another visitor)' => [null],
'a flash for another share' => ['another-share-token'],
]);
+140
View File
@@ -0,0 +1,140 @@
<?php
use App\Models\Setting;
use App\Services\PasswordGeneratorService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
pest()->extend(TestCase::class)
->use(RefreshDatabase::class);
beforeEach(function () {
$this->service = app(PasswordGeneratorService::class);
});
test('options fall back to the defaults when nothing is saved', function () {
expect($this->service->options())->toBe([
'mode' => 'button',
'type' => 'characters',
'length' => 20,
'characterSets' => ['uppercase', 'lowercase', 'numbers'],
'avoidAmbiguous' => true,
'words' => 6,
'separator' => 'hyphen',
]);
});
test('options read the saved generator settings', function () {
Setting::set('password_generator_mode', 'prefill');
Setting::set('password_generator_type', 'passphrase');
Setting::set('password_generator_length', 32);
Setting::set('password_generator_character_sets', 'numbers,symbols');
Setting::set('password_generator_avoid_ambiguous', '0');
Setting::set('password_generator_words', 8);
Setting::set('password_generator_separator', 'space');
expect($this->service->options())->toBe([
'mode' => 'prefill',
'type' => 'passphrase',
'length' => 32,
'characterSets' => ['numbers', 'symbols'],
'avoidAmbiguous' => false,
'words' => 8,
'separator' => 'space',
]);
});
test('options replace saved values that are not allowed with the defaults', function () {
Setting::set('password_generator_mode', 'sometimes');
Setting::set('password_generator_type', 'emoji');
Setting::set('password_generator_length', 8);
Setting::set('password_generator_character_sets', 'runes');
Setting::set('password_generator_words', 40);
Setting::set('password_generator_separator', 'comma');
expect($this->service->options())->toMatchArray([
'mode' => 'button',
'type' => 'characters',
'length' => 20,
'characterSets' => ['uppercase', 'lowercase', 'numbers'],
'words' => 6,
'separator' => 'hyphen',
]);
});
test('a character password has the chosen length and only characters from the chosen sets', function () {
$password = $this->service->generate([
'type' => 'characters',
'length' => 40,
'characterSets' => ['numbers'],
'avoidAmbiguous' => true,
'words' => 6,
'separator' => 'hyphen',
]);
expect($password)->toMatch('/^[2-9]{40}$/');
});
test('a character password holds at least one character of every chosen set', function () {
foreach (range(1, 25) as $draw) {
expect($this->service->characters(4, ['uppercase', 'lowercase', 'numbers', 'symbols'], false))
->toMatch('/[A-Z]/')
->toMatch('/[a-z]/')
->toMatch('/[0-9]/')
->toMatch('/[^A-Za-z0-9]/');
}
});
test('a character password leaves out look-alike characters when asked', function () {
foreach (range(1, 25) as $draw) {
expect($this->service->characters(64, ['uppercase', 'lowercase', 'numbers'], true))->not->toMatch('/[0O1lI]/');
}
});
test('a character password needs room for every chosen set', function () {
$this->service->characters(2, ['uppercase', 'lowercase', 'numbers'], false);
})->throws(InvalidArgumentException::class);
test('a passphrase has the chosen number of words from the word list, joined by the separator', function () {
$wordList = file(resource_path('wordlists/eff-large-wordlist.txt'), FILE_IGNORE_NEW_LINES);
$passphrase = $this->service->generate([
'type' => 'passphrase',
'length' => 20,
'characterSets' => ['uppercase'],
'avoidAmbiguous' => true,
'words' => 7,
'separator' => 'dot',
]);
$words = explode('.', $passphrase);
expect($words)->toHaveCount(7);
expect(array_diff($words, $wordList))->toBe([]);
});
test('the word list is EFF\'s large list without its hyphenated words', function () {
expect($this->service->wordList())
->toHaveCount(7772)
->each->toMatch('/^[a-z]+$/');
});
test('two generated passwords differ', function () {
expect($this->service->generate())->not->toBe($this->service->generate());
});
test('the entropy estimate follows the alphabet or the word list', function () {
expect($this->service->entropyBits([
'type' => 'characters',
'length' => 20,
'characterSets' => ['uppercase', 'lowercase', 'numbers'],
'avoidAmbiguous' => true,
]))->toBe(116);
expect($this->service->entropyBits([
'type' => 'passphrase',
'length' => 20,
'characterSets' => [],
'avoidAmbiguous' => false,
'words' => 6,
]))->toBe(77);
});
+1 -1
View File
@@ -218,7 +218,7 @@
<article class="feature">
<span class="badge-icon" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-lock"/></svg></span>
<h3>Password protection</h3>
<p>Protect a share with a password; its encryption key is derived from it.</p>
<p>Protect a share with a password, or generate one to copy; its encryption key is derived from it.</p>
</article>
<article class="feature">
<span class="badge-icon badge-icon--secondary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-encrypted"/></svg></span>