5 Commits
Author SHA1 Message Date
surtic86andClaude Opus 5.5 cf73052e0e Release 2.4.2
linter / quality (push) Successful in 1m3s
tests / ci (8.5) (push) Successful in 3m33s
docker / test (8.5) (push) Successful in 3m29s
docker / build-and-push (push) Successful in 50s
docker / release (push) Successful in 4s
Fixes the nine findings of a security audit of 2.4.1. The most
important: an anonymous uploader could fill the storage quota without
sending data, and a forged X-Forwarded-For header could get around the
limits on password attempts. Password attempts now also have a limit
per share, per login e-mail and for the system password.

Whoever entered the system password enters it once more after the
update. Update now: docker compose pull and docker compose up -d.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:36:31 +02:00
surtic86andClaude Opus 5.5 84c8b81f0a Fix the security audit's findings
Fixes the nine findings of security audit run 1 on 2.4.1 (T1-T11).

F1: Uploads that are not complete now count only the bytes the server
received, and a chunk that goes above the storage quota gets 422. An
anonymous uploader can no longer fill the quota with declared sizes.

F2: The share password and the admin login have a second limit without
the address (20 attempts per minute per share and per e-mail), because
a forged X-Forwarded-For changes the address.

F3: The system password gate and the admin check now also run on
Livewire updates, so an old uploader snapshot cannot register files.

F4: The session keeps a fingerprint of the system password's hash.
When the admin changes the system password, earlier verifications end.

F5: The page after the upload opens only in the session that created
the share, and only while recipients can still reach the share.

F6: The share password limit counts an attempt before the password is
checked, so parallel attempts cannot pass it.

F7: The system password has the same two limits (5 per address, 20 in
total per minute).

F8: A password change or reset signs the user's other sessions out.

F9: The only admin cannot delete the account, which would open /setup
to every visitor.

The setup wizard also holds the admin password to the password
defaults, and Livewire applies EnsureAdmin to updates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:46:38 +02:00
surtic86andClaude Opus 5.5 014184c824 Build Docker images only for releases
linter / quality (push) Successful in 1m3s
tests / ci (8.5) (push) Successful in 8m18s
A push to main built and pushed the full multi-arch image as :main and
sha-<commit>, next to the release build of the same commit. The docker
workflow now runs only for v*.*.* tags (and builds without pushing for
pull requests), and a release publishes only its version tags and
latest. The tests and lint workflows still run on each push to main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:18:56 +02:00
surtic86andClaude Opus 5.5 25250b7df3 Release 2.4.1
linter / quality (push) Canceled after 0s
tests / ci (8.5) (push) Canceled after 3s
docker / test (8.5) (push) Successful in 3m27s
docker / build-and-push (push) Successful in 13m7s
docker / release (push) Successful in 5s
The Docker image runs PHP 8.5.12, which fixes PHP2Shell
(CVE-2026-109618, CVE-2026-109619): two flaws that let an attacker
without an account run code on the server when a request ends
abnormally. Every earlier image runs an affected PHP.

Dependencies are up to date (Laravel 13.38, Livewire 4.4.8, Octane
2.22, Vite 8.3.5). Update now: docker compose pull and docker compose
up -d.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:16:39 +02:00
surtic86andClaude Opus 5.5 307e57b23c Update dependencies
Composer: Laravel 13.38, Octane 2.22, Livewire 4.4.8 and Pest 5.3.3,
with the packages they bring (35 in all). npm: Vite 8.3.5 with
Rolldown 1.2.15, and Playwright 1.64.1. chokidar stays on 5: a major
update does not go into a patch release. Both audits are clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:16:19 +02:00
31 changed files with 1005 additions and 372 deletions
-4
View File
@@ -2,8 +2,6 @@ name: docker
on: on:
push: push:
branches:
- main
tags: tags:
- "v*.*.*" - "v*.*.*"
pull_request: pull_request:
@@ -98,8 +96,6 @@ jobs:
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}} type=semver,pattern={{major}}
type=ref,event=branch
type=sha
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v7 uses: docker/build-push-action@v7
+29
View File
@@ -5,6 +5,33 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.4.2] - 2026-10-22
### Changed
- Whoever entered the system password enters it once more after the update. A change of the system password now ends every earlier entry.
- The page after an upload, with the link and the QR code, opens only in the browser session that created the share, and only while the share can still be downloaded. Other visitors get "not found".
- The admin password in the setup wizard follows the same rules as a changed password (in production: at least 12 characters, upper and lower case, numbers and symbols).
### Fixed
- Security: an anonymous uploader could reserve the whole storage quota with declared file sizes and send no data. Uploads that are not complete now count only the bytes received, and a chunk that goes above the quota is refused.
- Security: password attempts are limited per share, per login e-mail and for the system password (20 per minute), in addition to the limit per address (5 per minute), which a forged `X-Forwarded-For` header could change. The share password limit counts an attempt before the check, so parallel attempts cannot pass it. The system password had no limit.
- Security: the system password also protects the upload page's actions, so a page opened before the system password was set cannot register files.
- Security: the page after an upload showed a share's details to everyone with its link, also for a password-protected or expired share.
- Security: a password change or reset signs the admin's other sessions out.
- Security: the only admin can no longer delete their account, which opened the setup page to every visitor.
## [2.4.1] - 2026-10-22
### Changed
- Updated to Laravel 13.38, Livewire 4.4.8 and Laravel Octane 2.22, and Vite 8.3.5.
### Fixed
- Security: the Docker image runs PHP 8.5.12, which fixes PHP2Shell (CVE-2026-109618, CVE-2026-109619): two flaws that let an attacker without an account run code on the server when a request ends abnormally. Every earlier image runs an affected PHP (2.4.0 runs PHP 8.5.8). Update now: `docker compose pull` and `docker compose up -d`.
## [2.4.0] - 2026-10-08 ## [2.4.0] - 2026-10-08
### Added ### Added
@@ -200,6 +227,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Dark themed UI built with Livewire, Alpine.js, Tailwind CSS and DaisyUI. - Dark themed UI built with Livewire, Alpine.js, Tailwind CSS and DaisyUI.
- Docker images published to `ghcr.io/surtic86/sealshare`, served by FrankenPHP via Laravel Octane. - Docker images published to `ghcr.io/surtic86/sealshare`, served by FrankenPHP via Laravel Octane.
[2.4.2]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.4.1...v2.4.2
[2.4.1]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.4.0...v2.4.1
[2.4.0]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.3.0...v2.4.0 [2.4.0]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.3.0...v2.4.0
[2.3.0]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.2.0...v2.3.0 [2.3.0]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.2.0...v2.3.0
[2.2.0]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.1.0...v2.2.0 [2.2.0]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.1.0...v2.2.0
+4 -1
View File
@@ -17,7 +17,10 @@ class SystemPasswordGate
return $next($request); return $next($request);
} }
if ($request->session()->get('system_password_verified') === true) { // A fingerprint of the current hash: changing the system password ends every earlier verification.
$verified = $request->session()->get('system_password_verified');
if (is_string($verified) && hash_equals(hash('sha256', $systemPassword), $verified)) {
return $next($request); return $next($request);
} }
+1
View File
@@ -192,6 +192,7 @@ class FileUploader extends Component
]); ]);
session()->put('pending_shares', array_values(array_diff(session('pending_shares', []), [$share->token]))); session()->put('pending_shares', array_values(array_diff(session('pending_shares', []), [$share->token])));
session()->push('created_shares', $share->token);
// The page the upload leads to offers the password once more, next to the link; it is // 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. // never stored in the clear, so this flash is the only way it gets there.
+11 -1
View File
@@ -5,6 +5,7 @@ namespace App\Livewire;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
use Livewire\Component; use Livewire\Component;
@@ -18,11 +19,20 @@ class SetupWizard extends Component
#[Validate('required|string|email|max:255|unique:users')] #[Validate('required|string|email|max:255|unique:users')]
public string $email = ''; public string $email = '';
#[Validate('required|string|min:8|confirmed')]
public string $password = ''; public string $password = '';
public string $password_confirmation = ''; public string $password_confirmation = '';
/**
* @return array<string, array<int, mixed>>
*/
protected function rules(): array
{
return [
'password' => ['required', 'string', Password::defaults(), 'confirmed'],
];
}
public function mount(): void public function mount(): void
{ {
if (User::query()->where('is_admin', true)->exists()) { if (User::query()->where('is_admin', true)->exists()) {
+4 -2
View File
@@ -15,13 +15,15 @@ class ShareCreated extends Component
{ {
public Share $share; public Share $share;
/** The share's password, offered once to the uploader who just set it; `null` on any other visit. */ /** The share's password, offered once to the uploader who just set it; `null` on a reload. */
#[Locked] #[Locked]
public ?string $password = null; public ?string $password = null;
public function mount(Share $share): void public function mount(Share $share): void
{ {
abort_unless($share->isCompleted(), 404); // Only for the session that created the share, and only while recipients can still reach it.
abort_unless($share->isCompleted() && ! $share->isExpired() && ! $share->hasReachedDownloadLimit()
&& in_array($share->token, session('created_shares', []), true), 404);
$this->share = $share; $this->share = $share;
+13 -5
View File
@@ -39,10 +39,18 @@ class ShareDownload extends Component
public function verifyPassword(ShareService $shareService): void public function verifyPassword(ShareService $shareService): void
{ {
$rateLimitKey = 'share-password:'.$this->share->token.'|'.request()->ip(); $ipKey = 'share-password:'.$this->share->token.'|'.request()->ip();
$shareKey = 'share-password:'.$this->share->token;
if (RateLimiter::tooManyAttempts($rateLimitKey, 5)) { // Count before the slow hash check, so parallel requests cannot all pass a read-only check.
$seconds = RateLimiter::availableIn($rateLimitKey); $ipHits = RateLimiter::hit($ipKey, 60);
$shareHits = RateLimiter::hit($shareKey, 60);
if ($ipHits > 5 || $shareHits > 20) {
$seconds = max(
$ipHits > 5 ? RateLimiter::availableIn($ipKey) : 0,
$shareHits > 20 ? RateLimiter::availableIn($shareKey) : 0,
);
$this->addError('password', __('Too many attempts. Please try again in :seconds seconds.', ['seconds' => $seconds])); $this->addError('password', __('Too many attempts. Please try again in :seconds seconds.', ['seconds' => $seconds]));
return; return;
@@ -51,13 +59,13 @@ class ShareDownload extends Component
$this->validate(); $this->validate();
if (! $shareService->verifyPassword($this->share, $this->password)) { if (! $shareService->verifyPassword($this->share, $this->password)) {
RateLimiter::hit($rateLimitKey, 60);
$this->addError('password', __('The password is incorrect.')); $this->addError('password', __('The password is incorrect.'));
return; return;
} }
RateLimiter::clear($rateLimitKey); RateLimiter::clear($ipKey);
RateLimiter::clear($shareKey);
$encryptionKey = $shareService->getDecryptionKey($this->share, $this->password); $encryptionKey = $shareService->getDecryptionKey($this->share, $this->password);
session(['share_key_'.$this->share->token => $encryptionKey]); session(['share_key_'.$this->share->token => $encryptionKey]);
+22 -1
View File
@@ -4,6 +4,7 @@ namespace App\Livewire;
use App\Models\Setting; use App\Models\Setting;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
use Livewire\Component; use Livewire\Component;
@@ -16,6 +17,23 @@ class SystemPasswordPrompt extends Component
public function verify(): void public function verify(): void
{ {
$ipKey = 'system-password|'.request()->ip();
$globalKey = 'system-password';
// Count before the slow hash check, so parallel requests cannot all pass a read-only check.
$ipHits = RateLimiter::hit($ipKey, 60);
$globalHits = RateLimiter::hit($globalKey, 60);
if ($ipHits > 5 || $globalHits > 20) {
$seconds = max(
$ipHits > 5 ? RateLimiter::availableIn($ipKey) : 0,
$globalHits > 20 ? RateLimiter::availableIn($globalKey) : 0,
);
$this->addError('password', __('Too many attempts. Please try again in :seconds seconds.', ['seconds' => $seconds]));
return;
}
$this->validate(); $this->validate();
$systemPassword = Setting::get('system_password'); $systemPassword = Setting::get('system_password');
@@ -26,7 +44,10 @@ class SystemPasswordPrompt extends Component
return; return;
} }
session(['system_password_verified' => true]); RateLimiter::clear($ipKey);
RateLimiter::clear($globalKey);
session(['system_password_verified' => hash('sha256', $systemPassword)]);
$this->redirect(route('upload'), navigate: true); $this->redirect(route('upload'), navigate: true);
} }
+6
View File
@@ -2,12 +2,15 @@
namespace App\Providers; namespace App\Providers;
use App\Http\Middleware\EnsureAdmin;
use App\Http\Middleware\SystemPasswordGate;
use App\Models\Setting; use App\Models\Setting;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
use Livewire\Livewire;
use NoNameWeb\LivewireMaterial\Support\Scheme; use NoNameWeb\LivewireMaterial\Support\Scheme;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@@ -22,6 +25,9 @@ class AppServiceProvider extends ServiceProvider
// The colour profile the admin chose in Admin settings; asked each time a page, mail or // The colour profile the admin chose in Admin settings; asked each time a page, mail or
// error page draws its colours, so a new choice applies at once in every Octane worker. // error page draws its colours, so a new choice applies at once in every Octane worker.
Scheme::resolveProfileUsing(fn (): ?string => Setting::get('color_profile')); Scheme::resolveProfileUsing(fn (): ?string => Setting::get('color_profile'));
// Livewire updates apply a page route's middleware only when it is listed as persistent.
Livewire::addPersistentMiddleware([SystemPasswordGate::class, EnsureAdmin::class]);
} }
/** /**
+5 -1
View File
@@ -62,7 +62,11 @@ class FortifyServiceProvider extends ServiceProvider
RateLimiter::for('login', function (Request $request) { RateLimiter::for('login', function (Request $request) {
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip()); $throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
return Limit::perMinute(5)->by($throttleKey); // The address comes from X-Forwarded-For behind the proxy, so a second limit per e-mail does not use it.
return [
Limit::perMinute(5)->by($throttleKey),
Limit::perMinute(20)->by(Str::transliterate(Str::lower($request->input(Fortify::username())))),
];
}); });
} }
} }
+14 -3
View File
@@ -10,6 +10,7 @@ use Illuminate\Contracts\Session\Session;
use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@@ -104,7 +105,7 @@ class ShareService
* Verify the encrypted chunk that comes next for a file and write it into place; returns how * Verify the encrypted chunk that comes next for a file and write it into place; returns how
* many of the file's chunks are stored. The plaintext only exists in memory, to be checked. * many of the file's chunks are stored. The plaintext only exists in memory, to be checked.
* *
* @throws InvalidArgumentException when the chunk has the wrong length or fails authentication * @throws InvalidArgumentException when the chunk has the wrong length, exceeds the storage quota or fails authentication
* @throws ModelNotFoundException when the file was removed meanwhile * @throws ModelNotFoundException when the file was removed meanwhile
*/ */
public function storeChunk(ShareFile $file, int $index, string $chunk): int public function storeChunk(ShareFile $file, int $index, string $chunk): int
@@ -134,6 +135,10 @@ class ShareService
throw new InvalidArgumentException('Chunk '.$index.' has the wrong length'); throw new InvalidArgumentException('Chunk '.$index.' has the wrong length');
} }
if ($this->getTotalUsedSpace() + $plaintextLength > $this->getMaxStorageQuota()) {
throw new InvalidArgumentException('Chunk '.$index.' exceeds the storage quota');
}
try { try {
$plaintext = $this->encryptionService->decryptChunk($chunk, $share->encryption_key, $noncePrefix, $index, $isLast); $plaintext = $this->encryptionService->decryptChunk($chunk, $share->encryption_key, $noncePrefix, $index, $isLast);
} catch (RuntimeException) { } catch (RuntimeException) {
@@ -448,11 +453,17 @@ class ShareService
} }
/** /**
* Get total used space in bytes, files still being uploaded included. * Get total used space in bytes; files still being uploaded count with the bytes received so far.
*/ */
public function getTotalUsedSpace(): int public function getTotalUsedSpace(): int
{ {
return (int) Share::query()->sum('total_size'); $chunkSize = (int) config('uploads.chunk_size');
$received = ShareFile::query()
->whereHas('share', fn ($query) => $query->whereNull('completed_at'))
->sum(DB::raw("CASE WHEN uploaded_chunks * {$chunkSize} < file_size THEN uploaded_chunks * {$chunkSize} ELSE file_size END"));
return (int) Share::query()->whereNotNull('completed_at')->sum('total_size') + (int) $received;
} }
/** /**
+3
View File
@@ -16,6 +16,9 @@ return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
$middleware->trustProxies(at: '*'); $middleware->trustProxies(at: '*');
// Ends every other session of a user whose password changed.
$middleware->authenticateSessions();
$middleware->web(append: [ $middleware->web(append: [
EnsureSetupComplete::class, EnsureSetupComplete::class,
SecurityHeaders::class, SecurityHeaders::class,
Generated
+325 -238
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -25,6 +25,6 @@ return [
| |
*/ */
'version' => '2.4.0', 'version' => '2.4.2',
]; ];
+84 -84
View File
@@ -18,18 +18,18 @@
} }
}, },
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.153.0", "version": "0.155.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
"integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/oxc-project" "url": "https://github.com/sponsors/oxc-project"
} }
}, },
"node_modules/@rolldown/binding-android-arm-eabi": { "node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.13", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz",
"integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -43,9 +43,9 @@
} }
}, },
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.13", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz",
"integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -59,9 +59,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-arm64": { "node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.2.13", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz",
"integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -75,9 +75,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-x64": { "node_modules/@rolldown/binding-darwin-x64": {
"version": "1.2.13", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz",
"integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -91,9 +91,9 @@
} }
}, },
"node_modules/@rolldown/binding-freebsd-x64": { "node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.2.13", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz",
"integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -107,9 +107,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm-gnueabihf": { "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.2.13", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz",
"integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -123,9 +123,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-gnu": { "node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.2.13", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz",
"integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -142,9 +142,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-musl": { "node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.2.13", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz",
"integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -161,9 +161,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-ppc64-gnu": { "node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.2.13", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz",
"integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -180,9 +180,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-s390x-gnu": { "node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.2.13", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz",
"integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -199,9 +199,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-gnu": { "node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.2.13", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz",
"integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -218,9 +218,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-musl": { "node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.2.13", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz",
"integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -237,9 +237,9 @@
} }
}, },
"node_modules/@rolldown/binding-openharmony-arm64": { "node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.2.13", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz",
"integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -253,9 +253,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-arm64-msvc": { "node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.2.13", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz",
"integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -269,9 +269,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-x64-msvc": { "node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.2.13", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz",
"integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -670,13 +670,13 @@
} }
}, },
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.64.0", "version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
"integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.64.0" "playwright-core": "1.64.1"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@@ -686,9 +686,9 @@
} }
}, },
"node_modules/playwright-core": { "node_modules/playwright-core": {
"version": "1.64.0", "version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@@ -699,9 +699,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.29", "version": "8.5.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz",
"integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -718,7 +718,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.19", "nanoid": "^3.3.20",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.2" "source-map-js": "^1.2.2"
}, },
@@ -741,12 +741,12 @@
} }
}, },
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.2.13", "version": "1.2.15",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz",
"integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@oxc-project/types": "=0.153.0", "@oxc-project/types": "=0.155.0",
"@rolldown/pluginutils": "^1.0.0" "@rolldown/pluginutils": "^1.0.0"
}, },
"bin": { "bin": {
@@ -756,21 +756,21 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rolldown/binding-android-arm-eabi": "1.2.13", "@rolldown/binding-android-arm-eabi": "1.2.15",
"@rolldown/binding-android-arm64": "1.2.13", "@rolldown/binding-android-arm64": "1.2.15",
"@rolldown/binding-darwin-arm64": "1.2.13", "@rolldown/binding-darwin-arm64": "1.2.15",
"@rolldown/binding-darwin-x64": "1.2.13", "@rolldown/binding-darwin-x64": "1.2.15",
"@rolldown/binding-freebsd-x64": "1.2.13", "@rolldown/binding-freebsd-x64": "1.2.15",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.13", "@rolldown/binding-linux-arm-gnueabihf": "1.2.15",
"@rolldown/binding-linux-arm64-gnu": "1.2.13", "@rolldown/binding-linux-arm64-gnu": "1.2.15",
"@rolldown/binding-linux-arm64-musl": "1.2.13", "@rolldown/binding-linux-arm64-musl": "1.2.15",
"@rolldown/binding-linux-ppc64-gnu": "1.2.13", "@rolldown/binding-linux-ppc64-gnu": "1.2.15",
"@rolldown/binding-linux-s390x-gnu": "1.2.13", "@rolldown/binding-linux-s390x-gnu": "1.2.15",
"@rolldown/binding-linux-x64-gnu": "1.2.13", "@rolldown/binding-linux-x64-gnu": "1.2.15",
"@rolldown/binding-linux-x64-musl": "1.2.13", "@rolldown/binding-linux-x64-musl": "1.2.15",
"@rolldown/binding-openharmony-arm64": "1.2.13", "@rolldown/binding-openharmony-arm64": "1.2.15",
"@rolldown/binding-win32-arm64-msvc": "1.2.13", "@rolldown/binding-win32-arm64-msvc": "1.2.15",
"@rolldown/binding-win32-x64-msvc": "1.2.13" "@rolldown/binding-win32-x64-msvc": "1.2.15"
} }
}, },
"node_modules/source-map-js": { "node_modules/source-map-js": {
@@ -799,15 +799,15 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "8.3.4", "version": "8.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
"integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lightningcss": "^1.33.0", "lightningcss": "^1.33.0",
"picomatch": "^4.0.7", "picomatch": "^4.0.7",
"postcss": "^8.5.29", "postcss": "^8.5.30",
"rolldown": "~1.2.12", "rolldown": "~1.2.14",
"tinyglobby": "^0.2.17" "tinyglobby": "^0.2.17"
}, },
"bin": { "bin": {
@@ -824,7 +824,7 @@
}, },
"peerDependencies": { "peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0", "@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.7.1", "@vitejs/devtools": "^0.7.1 || ^0.8.0",
"esbuild": "^0.27.0 || ^0.28.0", "esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0", "jiti": ">=1.21.0",
"less": "^4.0.0", "less": "^4.0.0",
@@ -2,7 +2,9 @@
use App\Concerns\PasswordValidationRules; use App\Concerns\PasswordValidationRules;
use App\Livewire\Actions\Logout; use App\Livewire\Actions\Logout;
use App\Models\User;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Livewire\Component; use Livewire\Component;
new class extends Component { new class extends Component {
@@ -12,7 +14,15 @@ new class extends Component {
public bool $showDeleteModal = false; public bool $showDeleteModal = false;
/** /**
* Delete the currently authenticated user. * Whether the user is the only admin: deleting that account would open /setup to anyone.
*/
public function isLastAdmin(): bool
{
return Auth::user()->is_admin && ! User::query()->where('is_admin', true)->whereKeyNot(Auth::id())->exists();
}
/**
* Delete the currently authenticated user, unless it is the only admin.
*/ */
public function deleteUser(Logout $logout): void public function deleteUser(Logout $logout): void
{ {
@@ -20,13 +30,33 @@ new class extends Component {
'password' => $this->currentPasswordRules(), 'password' => $this->currentPasswordRules(),
]); ]);
// The admin rows stay locked until the delete, so two admins cannot delete each other's last peer at once.
$deleted = DB::transaction(function () use ($logout): bool {
User::query()->where('is_admin', true)->lockForUpdate()->get();
if ($this->isLastAdmin()) {
return false;
}
tap(Auth::user(), $logout(...))->delete(); tap(Auth::user(), $logout(...))->delete();
return true;
});
if (! $deleted) {
$this->addError('password', __('You are the only admin, so this account cannot be deleted.'));
return;
}
$this->redirect('/', navigate: true); $this->redirect('/', navigate: true);
} }
}; ?> }; ?>
<x-card :title="__('Delete account')" :subtitle="__('Delete your account and all of its resources')" heading="h2" variant="outlined"> <x-card :title="__('Delete account')" :subtitle="__('Delete your account and all of its resources')" heading="h2" variant="outlined">
@if ($this->isLastAdmin())
<p class="md-type-body-md md-ink-variant">{{ __('You are the only admin, so this account cannot be deleted.') }}</p>
@else
<x-button :label="__('Delete account')" danger icon="delete" wire:click="$set('showDeleteModal', true)" data-test="delete-user-button" /> <x-button :label="__('Delete account')" danger icon="delete" wire:click="$set('showDeleteModal', true)" data-test="delete-user-button" />
<x-modal wire:model="showDeleteModal" :title="__('Are you sure you want to delete your account?')" icon="delete"> <x-modal wire:model="showDeleteModal" :title="__('Are you sure you want to delete your account?')" icon="delete">
@@ -45,4 +75,5 @@ new class extends Component {
<x-button type="submit" form="delete-user-form" :label="__('Delete account')" danger data-test="confirm-delete-user-button" /> <x-button type="submit" form="delete-user-form" :label="__('Delete account')" danger data-test="confirm-delete-user-button" />
</x-slot:actions> </x-slot:actions>
</x-modal> </x-modal>
@endif
</x-card> </x-card>
+2
View File
@@ -136,6 +136,8 @@ test('the share-created page holds at every breakpoint', function () {
[], [],
); );
$this->withSession(['created_shares' => [$share->token]]);
walkBreakpoints(ready(visit(route('share.created', $share, false)))); walkBreakpoints(ready(visit(route('share.created', $share, false))));
}); });
+10 -1
View File
@@ -57,6 +57,8 @@ test('a new share\'s link can be copied from the page the upload leads to', func
[], [],
); );
$this->withSession(['created_shares' => [$share->token]]);
$page = ready(visit(route('share.created', $share, false))); $page = ready(visit(route('share.created', $share, false)));
$page->assertSee('Share Created!') $page->assertSee('Share Created!')
@@ -89,7 +91,10 @@ test('the new share\'s password stays masked on the page and is copied without b
[['file' => UploadedFile::fake()->create('contract.pdf', 80), 'relativePath' => null]], [['file' => UploadedFile::fake()->create('contract.pdf', 80), 'relativePath' => null]],
['password' => 'violet-orbit-canyon'], ['password' => 'violet-orbit-canyon'],
); );
$this->withSession(['share_password' => ['token' => $share->token, 'password' => Crypt::encryptString('violet-orbit-canyon')]]); $this->withSession([
'created_shares' => [$share->token],
'share_password' => ['token' => $share->token, 'password' => Crypt::encryptString('violet-orbit-canyon')],
]);
$page = ready(visit(route('share.created', $share, false))); $page = ready(visit(route('share.created', $share, false)));
@@ -109,6 +114,8 @@ test('the new share\'s password stays masked on the page and is copied without b
test('a new share\'s QR code opens in a dialog and saves as a PNG', function () { test('a new share\'s QR code opens in a dialog and saves as a PNG', function () {
$share = Share::factory()->withPassword()->create(); $share = Share::factory()->withPassword()->create();
$this->withSession(['created_shares' => [$share->token]]);
$page = ready(visit(route('share.created', $share, false))); $page = ready(visit(route('share.created', $share, false)));
$page->click('[data-test="show-qr-code"]') $page->click('[data-test="show-qr-code"]')
@@ -138,6 +145,8 @@ test('the share sheet gets the link, and says so only when it fails for another
$share = Share::factory()->create(); $share = Share::factory()->create();
$actions = "Alpine.\$data(document.querySelector('[data-test=share-actions]'))"; $actions = "Alpine.\$data(document.querySelector('[data-test=share-actions]'))";
$this->withSession(['created_shares' => [$share->token]]);
$page = ready(visit(route('share.created', $share, false))); $page = ready(visit(route('share.created', $share, false)));
// Shown only where the browser has a share sheet. // Shown only where the browser has a share sheet.
+2
View File
@@ -95,6 +95,8 @@ test('the drop zone hides its burst again once the drag leaves', function () {
test('the QR dialog holds its code inside the box, and Escape returns focus to the button that opened it', function () { test('the QR dialog holds its code inside the box, and Escape returns focus to the button that opened it', function () {
$share = Share::factory()->create(); $share = Share::factory()->create();
$this->withSession(['created_shares' => [$share->token]]);
$page = ready(visit(route('share.created', $share, false))); $page = ready(visit(route('share.created', $share, false)));
$page->click('[data-test="show-qr-code"]'); $page->click('[data-test="show-qr-code"]');
+2 -2
View File
@@ -151,7 +151,7 @@ test('the shares list excludes the private text from its file count and shows it
->assertDontSee('3 files'); ->assertDontSee('3 files');
}); });
test('shares whose files are still uploading are neither listed nor counted, but their bytes count as used space', function () { test('shares whose files are still uploading are neither listed nor counted, but only their received bytes count as used space', function () {
$admin = User::query()->where('is_admin', true)->first(); $admin = User::query()->where('is_admin', true)->first();
$completed = Share::factory()->create(['token' => 'completedshare01', 'total_size' => 1000]); $completed = Share::factory()->create(['token' => 'completedshare01', 'total_size' => 1000]);
ShareFile::factory()->for($completed)->create(); ShareFile::factory()->for($completed)->create();
@@ -165,5 +165,5 @@ test('shares whose files are still uploading are neither listed nor counted, but
->assertViewHas('totalShares', 1) ->assertViewHas('totalShares', 1)
->assertViewHas('activeShares', 1) ->assertViewHas('activeShares', 1)
->assertViewHas('totalFiles', 1) ->assertViewHas('totalFiles', 1)
->assertViewHas('usedSpace', 1500); ->assertViewHas('usedSpace', 1000);
}); });
+22
View File
@@ -37,6 +37,28 @@ test('users can not authenticate with invalid password', function () {
$this->assertGuest(); $this->assertGuest();
}); });
test('the login limit per e-mail holds when each attempt claims another address', function () {
$user = User::factory()->create();
for ($i = 1; $i <= 20; $i++) {
$response = $this->withHeader('X-Forwarded-For', "203.0.113.$i")->post(route('login.store'), [
'email' => $user->email,
'password' => 'wrong-password',
]);
if ($i === 1) {
expect($response->status())->not->toBe(429);
}
}
$this->withHeader('X-Forwarded-For', '203.0.113.21')->post(route('login.store'), [
'email' => $user->email,
'password' => 'password',
])->assertTooManyRequests();
$this->assertGuest();
});
test('users with two factor enabled are redirected to two factor challenge', function () { test('users with two factor enabled are redirected to two factor challenge', function () {
if (! Features::canManageTwoFactorAuthentication()) { if (! Features::canManageTwoFactorAuthentication()) {
$this->markTestSkipped('Two-factor authentication is not enabled.'); $this->markTestSkipped('Two-factor authentication is not enabled.');
+79 -2
View File
@@ -7,6 +7,7 @@ use App\Models\Share;
use App\Models\ShareFile; use App\Models\ShareFile;
use App\Services\ShareService; use App\Services\ShareService;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Livewire\Features\SupportTesting\Testable; use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire; use Livewire\Livewire;
@@ -54,14 +55,33 @@ test('upload page requires system password when configured', function () {
}); });
test('upload page accessible after system password verified', function () { test('upload page accessible after system password verified', function () {
Setting::set('system_password', bcrypt('system-secret')); $systemPassword = bcrypt('system-secret');
Setting::set('system_password', $systemPassword);
$response = $this->withSession(['system_password_verified' => true]) $response = $this->withSession(['system_password_verified' => hash('sha256', $systemPassword)])
->get(route('upload')); ->get(route('upload'));
$response->assertOk(); $response->assertOk();
}); });
test('a verification of an earlier system password no longer opens the upload page', function () {
$oldSystemPassword = bcrypt('system-secret');
Setting::set('system_password', $oldSystemPassword);
$this->withSession(['system_password_verified' => hash('sha256', $oldSystemPassword)]);
Setting::set('system_password', bcrypt('new-system-secret'));
$this->get(route('upload'))->assertRedirect(route('system-password'));
});
test('a verification stored before 2.4.2 no longer opens the upload page', function () {
Setting::set('system_password', bcrypt('system-secret'));
$this->withSession(['system_password_verified' => true])
->get(route('upload'))
->assertRedirect(route('system-password'));
});
test('file upload creates share', function () { test('file upload creates share', function () {
Storage::fake('shares'); Storage::fake('shares');
$component = Livewire::test(FileUploader::class); $component = Livewire::test(FileUploader::class);
@@ -74,6 +94,7 @@ test('file upload creates share', function () {
expect($share->isCompleted())->toBeTrue(); expect($share->isCompleted())->toBeTrue();
expect($share->files->pluck('original_name')->all())->toBe(['document.pdf']); expect($share->files->pluck('original_name')->all())->toBe(['document.pdf']);
expect(session('pending_shares'))->not->toContain($share->token); expect(session('pending_shares'))->not->toContain($share->token);
expect(session('created_shares'))->toBe([$share->token]);
}); });
test('registering files hands the browser each file\'s chunk URL, the share key and the file\'s nonce prefix', function () { test('registering files hands the browser each file\'s chunk URL, the share key and the file\'s nonce prefix', function () {
@@ -377,6 +398,27 @@ test('file upload blocks when storage is full', function () {
expect(ShareFile::query()->count())->toBe(0); expect(ShareFile::query()->count())->toBe(0);
}); });
test('files declared by one session without any chunk do not block another session', function () {
Storage::fake('shares');
Setting::set('max_file_size', 200 * 1024 * 1024);
Setting::set('max_size_per_share', 1024 * 1024 * 1024);
Setting::set('max_files_per_share', 10);
Setting::set('max_storage_quota', 300 * 1024 * 1024);
Livewire::test(FileUploader::class)->call('registerFiles', [
['name' => 'a.bin', 'size' => 100 * 1024 * 1024, 'path' => null],
['name' => 'b.bin', 'size' => 100 * 1024 * 1024, 'path' => null],
['name' => 'c.bin', 'size' => 100 * 1024 * 1024, 'path' => null],
])->assertHasNoErrors('files');
session()->flush();
$component = Livewire::test(FileUploader::class)
->call('registerFiles', [['name' => 'd.txt', 'size' => 1024, 'path' => null]]);
$component->assertHasNoErrors('files');
expect(ShareFile::query()->count())->toBe(4);
});
test('system password prompt verifies correct password', function () { test('system password prompt verifies correct password', function () {
Setting::set('system_password', bcrypt('system-secret')); Setting::set('system_password', bcrypt('system-secret'));
@@ -394,3 +436,38 @@ test('system password prompt rejects incorrect password', function () {
->call('verify') ->call('verify')
->assertHasErrors(['password']); ->assertHasErrors(['password']);
}); });
test('the system password prompt refuses a sixth attempt from the same address, even with the correct password', function () {
Setting::set('system_password', bcrypt('system-secret'));
$component = Livewire::test(SystemPasswordPrompt::class);
for ($i = 0; $i < 5; $i++) {
$component->set('password', 'wrong')
->call('verify')
->assertHasErrors(['password']);
}
$component->set('password', 'system-secret')
->call('verify')
->assertHasErrors(['password'])
->assertSee('Too many attempts')
->assertNoRedirect();
});
test('the global system password limit refuses the correct password from any address', function () {
Setting::set('system_password', bcrypt('system-secret'));
for ($i = 0; $i < 20; $i++) {
RateLimiter::hit('system-password', 60);
}
Livewire::test(SystemPasswordPrompt::class)
->set('password', 'system-secret')
->call('verify')
->assertHasErrors(['password'])
->assertSee('Too many attempts')
->assertNoRedirect();
expect(session()->has('system_password_verified'))->toBeFalse();
});
+5 -1
View File
@@ -26,7 +26,11 @@ test('every page is one page template, one h1 and the same width', function (Clo
})->with([ })->with([
'upload' => [fn (TestCase $test): TestResponse => $test->get(route('upload'))], 'upload' => [fn (TestCase $test): TestResponse => $test->get(route('upload'))],
'download' => [fn (TestCase $test): TestResponse => $test->get(route('share.download', Share::factory()->withPassword()->create()))], 'download' => [fn (TestCase $test): TestResponse => $test->get(route('share.download', Share::factory()->withPassword()->create()))],
'share created' => [fn (TestCase $test): TestResponse => $test->get(route('share.created', Share::factory()->create()))], 'share created' => [function (TestCase $test): TestResponse {
$share = Share::factory()->create();
return $test->withSession(['created_shares' => [$share->token]])->get(route('share.created', $share));
}],
'login' => [fn (TestCase $test): TestResponse => $test->get(route('login'))], 'login' => [fn (TestCase $test): TestResponse => $test->get(route('login'))],
'forgot password' => [fn (TestCase $test): TestResponse => $test->get(route('password.request'))], 'forgot password' => [fn (TestCase $test): TestResponse => $test->get(route('password.request'))],
'reset password' => [fn (TestCase $test): TestResponse => $test->get(route('password.reset', 'token'))], 'reset password' => [fn (TestCase $test): TestResponse => $test->get(route('password.reset', 'token'))],
+98
View File
@@ -3,11 +3,14 @@
use App\Livewire\Admin\AdminSettings; use App\Livewire\Admin\AdminSettings;
use App\Livewire\FileUploader; use App\Livewire\FileUploader;
use App\Livewire\ShareDownload; use App\Livewire\ShareDownload;
use App\Models\Setting;
use App\Models\Share; use App\Models\Share;
use App\Models\ShareFile; use App\Models\ShareFile;
use App\Models\User; use App\Models\User;
use App\Services\ShareService; use App\Services\ShareService;
use Illuminate\Contracts\Hashing\Hasher;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException; use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
@@ -106,6 +109,101 @@ test('rate limiter clears after successful password verification', function () {
expect(RateLimiter::remaining($rateLimitKey, 5))->toBe(5); expect(RateLimiter::remaining($rateLimitKey, 5))->toBe(5);
}); });
test('a share password attempt is counted before the password is checked', function () {
Storage::fake('shares');
$share = app(ShareService::class)->createShare([
['file' => UploadedFile::fake()->create('file.txt', 100), 'relativePath' => null],
], [
'password' => 'correct-password',
]);
$ipKey = 'share-password:'.$share->token.'|127.0.0.1';
$attemptsSeenDuringCheck = new ArrayObject;
Hash::swap(new class(Hash::getFacadeRoot(), $ipKey, $attemptsSeenDuringCheck) implements Hasher
{
public function __construct(private Hasher $inner, private string $ipKey, private ArrayObject $seen) {}
public function info($hashedValue): array
{
return $this->inner->info($hashedValue);
}
public function make(#[SensitiveParameter] $value, array $options = []): string
{
return $this->inner->make($value, $options);
}
public function check(#[SensitiveParameter] $value, $hashedValue, array $options = []): bool
{
$this->seen[] = RateLimiter::attempts($this->ipKey);
return $this->inner->check($value, $hashedValue, $options);
}
public function needsRehash($hashedValue, array $options = []): bool
{
return false;
}
});
Livewire::test(ShareDownload::class, ['share' => $share])
->set('password', 'wrong-password')
->call('verifyPassword');
expect($attemptsSeenDuringCheck->getArrayCopy())->not->toBeEmpty()
->each->toBeGreaterThanOrEqual(1);
});
test('the share-wide limit refuses the correct password from any address', function () {
Storage::fake('shares');
$share = app(ShareService::class)->createShare([
['file' => UploadedFile::fake()->create('file.txt', 100), 'relativePath' => null],
], [
'password' => 'correct-password',
]);
for ($i = 0; $i < 20; $i++) {
RateLimiter::hit('share-password:'.$share->token, 60);
}
Livewire::test(ShareDownload::class, ['share' => $share])
->set('password', 'correct-password')
->call('verifyPassword')
->assertHasErrors(['password'])
->assertSee('Too many attempts')
->assertSet('authenticated', false);
});
// --- The system password gate holds for Livewire updates ---
test('a file uploader snapshot from before the system password was set cannot register files in an unverified session', function () {
Storage::fake('shares');
preg_match_all('/wire:snapshot="([^"]+)"/', $this->get(route('upload'))->assertOk()->getContent(), $matches);
$snapshot = collect($matches[1])
->map(fn (string $raw): array => json_decode(htmlspecialchars_decode($raw, ENT_QUOTES), true))
->first(fn (array $snapshot): bool => str_contains($snapshot['memo']['name'] ?? '', 'file-uploader'));
expect($snapshot)->not->toBeNull();
Setting::set('system_password', Hash::make('system-secret'));
$this->flushSession();
$response = $this->withHeaders(['X-Livewire' => '1'])
->postJson(app('livewire')->getUpdateUri(), [
'components' => [[
'snapshot' => json_encode($snapshot),
'updates' => [],
'calls' => [['method' => 'registerFiles', 'params' => [[['name' => 'file.bin', 'size' => 1024]]], 'path' => '']],
]],
]);
$response->assertRedirect(route('system-password'));
expect(Share::query()->count())->toBe(0);
});
// --- Share password minimum length --- // --- Share password minimum length ---
test('share password must be at least 8 characters', function () { test('share password must be at least 8 characters', function () {
@@ -1,9 +1,41 @@
<?php <?php
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Livewire\Livewire; use Livewire\Livewire;
/**
* Drop the guards and the session store, so the next request authenticates from its cookie only.
*/
function forgetRequestState(): void
{
Auth::forgetGuards();
app('session')->forgetDrivers();
app()->forgetInstance('session.store');
test()->flushSession();
}
/**
* Sign the admin in on a database session, use it once as an admin would, and return its ID.
*/
function signedInAdminSessionId(User $admin): string
{
config(['session.driver' => 'database']);
$sessionId = test()->post(route('login.store'), ['email' => $admin->email, 'password' => 'Old-Password-1!'])
->assertRedirect()
->getCookie(config('session.cookie'))
->getValue();
forgetRequestState();
test()->withCookie(config('session.cookie'), $sessionId)->get(route('admin.dashboard'))->assertOk();
forgetRequestState();
return $sessionId;
}
test('password can be updated', function () { test('password can be updated', function () {
$user = User::factory()->create([ $user = User::factory()->create([
'password' => Hash::make('password'), 'password' => Hash::make('password'),
@@ -38,3 +70,65 @@ test('correct password must be provided to update password', function () {
$response->assertHasErrors(['current_password']); $response->assertHasErrors(['current_password']);
}); });
test('changing the password signs the user\'s other sessions out', function () {
$admin = User::factory()->admin()->create(['password' => Hash::make('Old-Password-1!')]);
$sessionId = signedInAdminSessionId($admin);
Livewire::actingAs($admin)->test('pages::settings.password')
->set('current_password', 'Old-Password-1!')
->set('password', 'New-Password-2!xY')
->set('password_confirmation', 'New-Password-2!xY')
->call('updatePassword')
->assertHasNoErrors();
forgetRequestState();
$this->withCookie(config('session.cookie'), $sessionId)
->get(route('admin.dashboard'))
->assertRedirect(route('login'));
});
test('resetting the password signs the user\'s sessions out', function () {
$admin = User::factory()->admin()->create(['password' => Hash::make('Old-Password-1!')]);
$sessionId = signedInAdminSessionId($admin);
// The reset comes from a browser that is not signed in, without the session's cookie.
$this->defaultCookies = [];
$this->post(route('password.update'), [
'token' => Password::broker()->createToken($admin),
'email' => $admin->email,
'password' => 'New-Password-3!xY',
'password_confirmation' => 'New-Password-3!xY',
])->assertSessionHasNoErrors();
forgetRequestState();
$this->withCookie(config('session.cookie'), $sessionId)
->get(route('admin.dashboard'))
->assertRedirect(route('login'));
});
test('the session that changes the password stays signed in', function () {
$admin = User::factory()->admin()->create(['password' => Hash::make('Old-Password-1!')]);
$sessionId = signedInAdminSessionId($admin);
preg_match('/wire:snapshot="([^"]+)"/', $this->withCookie(config('session.cookie'), $sessionId)->get(route('user-password.edit'))->assertOk()->getContent(), $matches);
forgetRequestState();
$this->withCookie(config('session.cookie'), $sessionId)
->withCredentials()
->withHeaders(['X-Livewire' => '1'])
->postJson(app('livewire')->getUpdateUri(), [
'components' => [[
'snapshot' => htmlspecialchars_decode($matches[1], ENT_QUOTES),
'updates' => ['current_password' => 'Old-Password-1!', 'password' => 'New-Password-2!xY', 'password_confirmation' => 'New-Password-2!xY'],
'calls' => [['method' => 'updatePassword', 'params' => [], 'path' => '']],
]],
])
->assertOk();
expect(Hash::check('New-Password-2!xY', $admin->refresh()->password))->toBeTrue();
forgetRequestState();
$this->withCookie(config('session.cookie'), $sessionId)
->get(route('admin.dashboard'))
->assertOk();
});
@@ -74,3 +74,40 @@ test('correct password must be provided to delete account', function () {
expect($user->fresh())->not->toBeNull(); expect($user->fresh())->not->toBeNull();
}); });
test('the only admin cannot delete their account', function () {
$admin = User::query()->where('is_admin', true)->sole();
$this->actingAs($admin);
Livewire::test('pages::settings.delete-user-form')
->set('password', 'password')
->call('deleteUser')
->assertHasErrors(['password'])
->assertNoRedirect();
expect($admin->fresh())->not->toBeNull();
expect(auth()->check())->toBeTrue();
});
test('an admin can delete their account while another admin remains', function () {
$admin = User::factory()->admin()->create();
$this->actingAs($admin);
Livewire::test('pages::settings.delete-user-form')
->set('password', 'password')
->call('deleteUser')
->assertHasNoErrors()
->assertRedirect('/');
expect($admin->fresh())->toBeNull();
});
test('the only admin\'s account card explains why it cannot be deleted instead of offering the button', function () {
$this->actingAs(User::query()->where('is_admin', true)->sole());
Livewire::test('pages::settings.delete-user-form')
->assertSee('You are the only admin, so this account cannot be deleted.')
->assertDontSee('data-test="delete-user-button"', false);
});
+16
View File
@@ -2,6 +2,7 @@
use App\Livewire\SetupWizard; use App\Livewire\SetupWizard;
use App\Models\User; use App\Models\User;
use Illuminate\Validation\Rules\Password;
use Livewire\Livewire; use Livewire\Livewire;
test('setup wizard renders when no admin exists', function () { test('setup wizard renders when no admin exists', function () {
@@ -38,6 +39,21 @@ test('setup wizard creates admin user', function () {
expect($admin->is_admin)->toBeTrue(); expect($admin->is_admin)->toBeTrue();
}); });
test('the setup wizard holds the admin password to the password defaults', function () {
User::query()->where('is_admin', true)->delete();
Password::defaults(fn () => Password::min(12)->mixedCase()->symbols());
Livewire::test(SetupWizard::class)
->set('name', 'Admin User')
->set('email', 'admin@example.com')
->set('password', 'password123')
->set('password_confirmation', 'password123')
->call('createAdmin')
->assertHasErrors(['password']);
expect(User::query()->exists())->toBeFalse();
});
test('setup wizard validates required fields', function () { test('setup wizard validates required fields', function () {
User::query()->where('is_admin', true)->delete(); User::query()->where('is_admin', true)->delete();
+28 -5
View File
@@ -12,7 +12,7 @@ test('the share created page offers the link as a QR code and through the share
$url = route('share.download', $share); $url = route('share.download', $share);
$svg = app(QrCodeService::class)->svg($url); $svg = app(QrCodeService::class)->svg($url);
$response = $this->get(route('share.created', $share)); $response = $this->withSession(['created_shares' => [$share->token]])->get(route('share.created', $share));
$response->assertOk() $response->assertOk()
->assertSee($url, false) ->assertSee($url, false)
@@ -30,7 +30,8 @@ test('the share created page offers the link as a QR code and through the share
test('the QR code dialog reminds that a protected share also needs its password', function () { test('the QR code dialog reminds that a protected share also needs its password', function () {
$share = Share::factory()->withPassword()->create(); $share = Share::factory()->withPassword()->create();
$this->get(route('share.created', $share)) $this->withSession(['created_shares' => [$share->token]])
->get(route('share.created', $share))
->assertOk() ->assertOk()
->assertSee('Recipients also need the password.'); ->assertSee('Recipients also need the password.');
}); });
@@ -38,7 +39,10 @@ test('the QR code dialog reminds that a protected share also needs its password'
test('the uploader who just set the password can copy it beside the link, without it being on screen', function () { 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(); $share = Share::factory()->withPassword()->create();
$response = $this->withSession(['share_password' => ['token' => $share->token, 'password' => Crypt::encryptString('violet-orbit-canyon')]]) $response = $this->withSession([
'created_shares' => [$share->token],
'share_password' => ['token' => $share->token, 'password' => Crypt::encryptString('violet-orbit-canyon')],
])
->get(route('share.created', $share)); ->get(route('share.created', $share));
$response->assertSee('data-test="share-password"', false) $response->assertSee('data-test="share-password"', false)
@@ -52,7 +56,11 @@ test('the uploader who just set the password can copy it beside the link, withou
test('the password is not shown without a flash for this share', function (?string $flashedFor) { test('the password is not shown without a flash for this share', function (?string $flashedFor) {
$share = Share::factory()->withPassword()->create(); $share = Share::factory()->withPassword()->create();
$session = $flashedFor === null ? [] : ['share_password' => ['token' => $flashedFor, 'password' => Crypt::encryptString('violet-orbit-canyon')]]; $session = ['created_shares' => [$share->token]];
if ($flashedFor !== null) {
$session['share_password'] = ['token' => $flashedFor, 'password' => Crypt::encryptString('violet-orbit-canyon')];
}
$response = $this->withSession($session)->get(route('share.created', $share)); $response = $this->withSession($session)->get(route('share.created', $share));
@@ -75,8 +83,23 @@ test('the Private text stat shows for a share with text, and the Files stat excl
'the secret note', 'the secret note',
); );
$response = $this->get(route('share.created', $share)); $response = $this->withSession(['created_shares' => [$share->token]])->get(route('share.created', $share));
$response->assertOk() $response->assertOk()
->assertSeeInOrder(['Files', '2', 'Private text']); ->assertSeeInOrder(['Files', '2', 'Private text']);
}); });
test('the share created page is not found for a session that did not create the share', function () {
$share = Share::factory()->withPassword()->create();
$this->get(route('share.created', $share))->assertNotFound();
});
test('the share created page is not found once the share is no longer reachable', function (Share $share) {
$this->withSession(['created_shares' => [$share->token]])
->get(route('share.created', $share))
->assertNotFound();
})->with([
'expired' => fn () => Share::factory()->expired()->create(),
'at its download limit' => fn () => Share::factory()->withMaxDownloads(1)->create(['download_count' => 1]),
]);
+14
View File
@@ -1,6 +1,7 @@
<?php <?php
use App\Models\Setting; use App\Models\Setting;
use App\Models\Share;
use App\Models\ShareFile; use App\Models\ShareFile;
use App\Services\ShareService; use App\Services\ShareService;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -31,6 +32,19 @@ test('a chunk for a pending share this session started is stored', function () {
expect($file->refresh()->uploaded_chunks)->toBe(1); expect($file->refresh()->uploaded_chunks)->toBe(1);
}); });
test('a chunk that takes the stored bytes beyond the storage quota returns 422', function () {
Storage::fake('shares');
config(['uploads.chunk_size' => 4]);
Setting::set('max_storage_quota', 100);
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 6, null);
Share::factory()->create(['total_size' => 98]);
$response = putChunk($this, $file, 0, encryptedChunk($file, 'abcd', 0, false), [$file->share->token]);
$response->assertStatus(422);
expect($file->refresh()->uploaded_chunks)->toBe(0);
});
test('a chunk for a pending share another session started returns 404', function () { test('a chunk for a pending share another session started returns 404', function () {
Storage::fake('shares'); Storage::fake('shares');
$file = app(ShareService::class)->registerFile(null, 'notes.txt', 4, null); $file = app(ShareService::class)->registerFile(null, 'notes.txt', 4, null);
+1
View File
@@ -172,6 +172,7 @@ test('phone', function (string $theme) use ($files) {
$page->script("Alpine.\$data(document.querySelector('[data-test=share-file-list]')).supported = false"); $page->script("Alpine.\$data(document.querySelector('[data-test=share-file-list]')).supported = false");
shoot($page, 'phone', $theme, '03-download'); shoot($page, 'phone', $theme, '03-download');
$this->withSession(['created_shares' => [DemoData::DELIVERY_TOKEN]]);
$created = visit(route('share.created', DemoData::DELIVERY_TOKEN, false)); $created = visit(route('share.created', DemoData::DELIVERY_TOKEN, false));
$page = shotPage($created, 'phone', $theme); $page = shotPage($created, 'phone', $theme);
$page->click('[data-test="show-qr-code"]') $page->click('[data-test="show-qr-code"]')
+25 -3
View File
@@ -230,14 +230,36 @@ test('a file that takes the share beyond the admin size per share is rejected',
->toThrow(ValidationException::class, 'Total file size exceeds the maximum allowed per share.'); ->toThrow(ValidationException::class, 'Total file size exceeds the maximum allowed per share.');
}); });
test('a file that does not fit the storage quota beside files still uploading is rejected', function () { test('a file that does not fit the storage quota beside the bytes already received is rejected', function () {
config(['uploads.chunk_size' => 10]);
Setting::set('max_storage_quota', 100); Setting::set('max_storage_quota', 100);
Share::factory()->pending()->create(['total_size' => 60]); $pending = Share::factory()->pending()->create(['total_size' => 60]);
ShareFile::factory()->for($pending)->uploading()->create(['file_size' => 60, 'uploaded_chunks' => 1]);
expect(fn () => $this->service->registerFile(null, 'file.txt', 50, null)) expect(fn () => $this->service->registerFile(null, 'file.txt', 95, null))
->toThrow(ValidationException::class, 'Storage is full. Please contact the administrator.'); ->toThrow(ValidationException::class, 'Storage is full. Please contact the administrator.');
}); });
test('a file still uploading without received chunks does not use up the storage quota', function () {
config(['uploads.chunk_size' => 10]);
Setting::set('max_storage_quota', 100);
$pending = Share::factory()->pending()->create(['total_size' => 60]);
ShareFile::factory()->for($pending)->uploading()->create(['file_size' => 60, 'uploaded_chunks' => 0]);
expect($this->service->registerFile(null, 'file.txt', 50, null))->toBeInstanceOf(ShareFile::class);
});
test('a chunk that takes the stored bytes beyond the storage quota is rejected', function () {
config(['uploads.chunk_size' => 4]);
Setting::set('max_storage_quota', 100);
$file = $this->service->registerFile(null, 'notes.txt', 6, null);
Share::factory()->create(['total_size' => 98]);
expect(fn () => $this->service->storeChunk($file, 0, encryptedChunk($file, 'abcd', 0, false)))
->toThrow(InvalidArgumentException::class, 'Chunk 0 exceeds the storage quota');
expect($file->refresh()->uploaded_chunks)->toBe(0);
});
test('chunks stored in order complete the file', function () { test('chunks stored in order complete the file', function () {
config(['uploads.chunk_size' => 4]); config(['uploads.chunk_size' => 4]);
$file = $this->service->registerFile(null, 'notes.txt', 6, null); $file = $this->service->registerFile(null, 'notes.txt', 6, null);