Compare commits
5
Commits
368a3b8cb5
..
v2.4.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf73052e0e | ||
|
|
84c8b81f0a | ||
|
|
014184c824 | ||
|
|
25250b7df3 | ||
|
|
307e57b23c |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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())))),
|
||||||
|
];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -25,6 +25,6 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'version' => '2.4.0',
|
'version' => '2.4.2',
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
Generated
+84
-84
@@ -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>
|
||||||
|
|||||||
@@ -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))));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"]');
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -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'))],
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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]),
|
||||||
|
]);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"]')
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user