Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
c45d6e2f5e Bump the minor-updates group across 1 directory with 20 updates
Bumps the minor-updates group with 17 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [dedoc/scramble](https://github.com/dedoc/scramble) | `0.12.36` | `0.13.43` |
| [flowframe/laravel-trend](https://github.com/Flowframe/laravel-trend) | `0.4.0` | `0.5.0` |
| [gotenberg/gotenberg-php](https://github.com/gotenberg/gotenberg-php) | `2.21.0` | `2.25.0` |
| [laravel/fortify](https://github.com/laravel/fortify) | `1.37.2` | `1.39.0` |
| [laravel/octane](https://github.com/laravel/octane) | `2.17.4` | `2.19.1` |
| [laravel/passport](https://github.com/laravel/passport) | `13.7.5` | `13.8.0` |
| [league/flysystem-aws-s3-v3](https://github.com/thephpleague/flysystem-aws-s3-v3) | `3.34.0` | `3.35.3` |
| [league/iso3166](https://github.com/alcohol/iso3166) | `4.4.0` | `4.5.0` |
| [owen-it/laravel-auditing](https://github.com/owen-it/laravel-auditing) | `14.0.3` | `14.0.6` |
| [spatie/temporary-directory](https://github.com/spatie/temporary-directory) | `2.3.1` | `2.4.0` |
| [tightenco/ziggy](https://github.com/tighten/ziggy) | `2.6.2` | `2.6.4` |
| [fumeapp/modeltyper](https://github.com/fumeapp/modeltyper) | `3.10.0` | `3.13.0` |
| [larastan/larastan](https://github.com/larastan/larastan) | `3.9.6` | `3.12.0` |
| [laravel/pint](https://github.com/laravel/pint) | `1.29.1` | `1.32.1` |
| [laravel/sail](https://github.com/laravel/sail) | `1.60.0` | `1.67.0` |
| [laravel/telescope](https://github.com/laravel/telescope) | `5.20.0` | `5.24.0` |
| [nunomaduro/collision](https://github.com/nunomaduro/collision) | `8.9.4` | `8.9.5` |



Updates `dedoc/scramble` from 0.12.36 to 0.13.43
- [Release notes](https://github.com/dedoc/scramble/releases)
- [Commits](https://github.com/dedoc/scramble/compare/v0.12.36...0.13.43)

Updates `flowframe/laravel-trend` from 0.4.0 to 0.5.0
- [Release notes](https://github.com/Flowframe/laravel-trend/releases)
- [Changelog](https://github.com/Flowframe/laravel-trend/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Flowframe/laravel-trend/compare/v0.4.0...v0.5.0)

Updates `gotenberg/gotenberg-php` from 2.21.0 to 2.25.0
- [Release notes](https://github.com/gotenberg/gotenberg-php/releases)
- [Commits](https://github.com/gotenberg/gotenberg-php/compare/v2.21.0...v2.25.0)

Updates `guzzlehttp/guzzle` from 7.10.3 to 7.15.5
- [Release notes](https://github.com/guzzle/guzzle/releases)
- [Changelog](https://github.com/guzzle/guzzle/blob/7.15.5/CHANGELOG.md)
- [Commits](https://github.com/guzzle/guzzle/compare/7.10.3...7.15.5)

Updates `laravel/fortify` from 1.37.2 to 1.39.0
- [Release notes](https://github.com/laravel/fortify/releases)
- [Changelog](https://github.com/laravel/fortify/blob/1.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/fortify/compare/v1.37.2...v1.39.0)

Updates `laravel/framework` from 12.60.2 to 12.69.2
- [Release notes](https://github.com/laravel/framework/releases)
- [Changelog](https://github.com/laravel/framework/blob/13.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/framework/compare/v12.60.2...v12.69.2)

Updates `laravel/octane` from 2.17.4 to 2.19.1
- [Release notes](https://github.com/laravel/octane/releases)
- [Changelog](https://github.com/laravel/octane/blob/2.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/octane/compare/v2.17.4...v2.19.1)

Updates `laravel/passport` from 13.7.5 to 13.8.0
- [Release notes](https://github.com/laravel/passport/releases)
- [Changelog](https://github.com/laravel/passport/blob/13.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/passport/compare/v13.7.5...v13.8.0)

Updates `league/flysystem-aws-s3-v3` from 3.34.0 to 3.35.3
- [Commits](https://github.com/thephpleague/flysystem-aws-s3-v3/compare/3.34.0...3.35.3)

Updates `league/iso3166` from 4.4.0 to 4.5.0
- [Release notes](https://github.com/alcohol/iso3166/releases)
- [Changelog](https://github.com/alcohol/iso3166/blob/main/CHANGELOG.md)
- [Commits](https://github.com/alcohol/iso3166/compare/4.4.0...4.5.0)

Updates `owen-it/laravel-auditing` from 14.0.3 to 14.0.6
- [Release notes](https://github.com/owen-it/laravel-auditing/releases)
- [Changelog](https://github.com/owen-it/laravel-auditing/blob/master/CHANGELOG.md)
- [Commits](https://github.com/owen-it/laravel-auditing/compare/v14.0.3...v14.0.6)

Updates `spatie/temporary-directory` from 2.3.1 to 2.4.0
- [Release notes](https://github.com/spatie/temporary-directory/releases)
- [Changelog](https://github.com/spatie/temporary-directory/blob/main/CHANGELOG.md)
- [Commits](https://github.com/spatie/temporary-directory/compare/2.3.1...2.4.0)

Updates `tightenco/ziggy` from 2.6.2 to 2.6.4
- [Release notes](https://github.com/tighten/ziggy/releases)
- [Changelog](https://github.com/tighten/ziggy/blob/2.x/CHANGELOG.md)
- [Commits](https://github.com/tighten/ziggy/compare/v2.6.2...v2.6.4)

Updates `fumeapp/modeltyper` from 3.10.0 to 3.13.0
- [Release notes](https://github.com/fumeapp/modeltyper/releases)
- [Commits](https://github.com/fumeapp/modeltyper/compare/v3.10.0...v3.13.0)

Updates `larastan/larastan` from 3.9.6 to 3.12.0
- [Release notes](https://github.com/larastan/larastan/releases)
- [Changelog](https://github.com/larastan/larastan/blob/3.x/RELEASE.md)
- [Commits](https://github.com/larastan/larastan/compare/v3.9.6...v3.12.0)

Updates `laravel/pint` from 1.29.1 to 1.32.1
- [Release notes](https://github.com/laravel/pint/releases)
- [Changelog](https://github.com/laravel/pint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/laravel/pint/compare/v1.29.1...v1.32.1)

Updates `laravel/sail` from 1.60.0 to 1.67.0
- [Release notes](https://github.com/laravel/sail/releases)
- [Changelog](https://github.com/laravel/sail/blob/1.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/sail/compare/v1.60.0...v1.67.0)

Updates `laravel/telescope` from 5.20.0 to 5.24.0
- [Release notes](https://github.com/laravel/telescope/releases)
- [Changelog](https://github.com/laravel/telescope/blob/5.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/telescope/compare/v5.20.0...v5.24.0)

Updates `mockery/mockery` from 1.6.12 to 1.6.15
- [Release notes](https://github.com/mockery/mockery/releases)
- [Changelog](https://github.com/mockery/mockery/blob/1.6.x/CHANGELOG.md)
- [Commits](https://github.com/mockery/mockery/compare/1.6.12...1.6.15)

Updates `nunomaduro/collision` from 8.9.4 to 8.9.5
- [Commits](https://github.com/nunomaduro/collision/compare/v8.9.4...v8.9.5)

---
updated-dependencies:
- dependency-name: dedoc/scramble
  dependency-version: 0.13.43
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: flowframe/laravel-trend
  dependency-version: 0.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: gotenberg/gotenberg-php
  dependency-version: 2.25.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: guzzlehttp/guzzle
  dependency-version: 7.15.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/fortify
  dependency-version: 1.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/framework
  dependency-version: 12.69.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/octane
  dependency-version: 2.19.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/passport
  dependency-version: 13.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: league/flysystem-aws-s3-v3
  dependency-version: 3.35.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: league/iso3166
  dependency-version: 4.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: owen-it/laravel-auditing
  dependency-version: 14.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-updates
- dependency-name: spatie/temporary-directory
  dependency-version: 2.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: tightenco/ziggy
  dependency-version: 2.6.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-updates
- dependency-name: fumeapp/modeltyper
  dependency-version: 3.13.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: larastan/larastan
  dependency-version: 3.12.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/pint
  dependency-version: 1.32.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/sail
  dependency-version: 1.67.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/telescope
  dependency-version: 5.24.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: mockery/mockery
  dependency-version: 1.6.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-updates
- dependency-name: nunomaduro/collision
  dependency-version: 8.9.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-14 08:06:58 +00:00
26 changed files with 184 additions and 1025 deletions

View File

@@ -1,37 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Auth;
use Illuminate\Auth\EloquentUserProvider;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* User provider that only resolves non-placeholder users.
*
* Placeholder users are created by imports and when members are removed from an
* organization. They can share an email address with a real user, so resolving a user by
* email can return a placeholder instead of the real account. The login flow filters them
* out explicitly, but the password broker and the guard credential checks (for example the
* password confirmation) resolve users through the configured user provider.
*
* Registered as the "eloquent" provider driver in the AuthServiceProvider, so it replaces the
* built-in one for every provider in config/auth.php.
*/
class ActiveUserProvider extends EloquentUserProvider
{
/**
* @param Model|null $model
* @return Builder<Model>
*/
#[\Override]
protected function newModelQuery($model = null): Builder
{
$query = parent::newModelQuery($model);
$query->getQuery()->where('is_placeholder', '=', false);
return $query;
}
}

View File

@@ -24,7 +24,6 @@ class ImportRequest extends BaseFormRequest
'data' => [
'required',
'string',
'max:'.config('import.max_data_size'),
],
];
}

View File

@@ -38,10 +38,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property string|null $pending_email
* @property Carbon|null $email_verified_at
* @property string|null $password
* @property string|null $remember_token
* @property string|null $two_factor_secret
* @property string|null $two_factor_recovery_codes
* @property Carbon|null $two_factor_confirmed_at
* @property string $timezone
* @property bool $is_placeholder
* @property Weekday $week_start
@@ -153,9 +150,7 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
public function canAccessPanel(Panel $panel): bool
{
return $this->is_placeholder === false
&& in_array($this->email, config('auth.super_admins', []), true)
&& $this->hasVerifiedEmail();
return in_array($this->email, config('auth.super_admins', []), true) && $this->hasVerifiedEmail();
}
public function isMemberOfOrganization(Organization $organization): bool

View File

@@ -4,14 +4,11 @@ declare(strict_types=1);
namespace App\Providers;
use App\Auth\ActiveUserProvider;
use App\Models\Passport\AuthCode;
use App\Models\Passport\Client;
use App\Models\Passport\RefreshToken;
use App\Models\Passport\Token;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Auth;
use Laravel\Passport\Passport;
class AuthServiceProvider extends ServiceProvider
@@ -29,13 +26,6 @@ class AuthServiceProvider extends ServiceProvider
*/
public function boot(): void
{
// Replaces the built-in eloquent user provider, so that no authentication flow can
// resolve a placeholder user. The driver name is kept, because Passport recognizes
// only providers that are configured with the driver "eloquent".
Auth::provider('eloquent', function (Application $app, array $config): ActiveUserProvider {
return new ActiveUserProvider($app->make('hash'), $config['model']);
});
// define scopes for passport tokens
Passport::tokensCan([
'create' => 'Create resources',

View File

@@ -9,8 +9,11 @@ use App\Service\Import\Importers\ImporterContract;
use App\Service\Import\Importers\ImporterProvider;
use App\Service\Import\Importers\ImportException;
use App\Service\Import\Importers\ReportDto;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class ImportService
{
@@ -22,6 +25,8 @@ class ImportService
/** @var ImporterContract $importer */
$importer = app(ImporterProvider::class)->getImporter($importerType);
$importer->init($organization);
Storage::disk(config('filesystems.default'))
->put('import/'.Carbon::now()->toDateString().'-'.$organization->getKey().'-'.Str::uuid(), $data);
$lock = Cache::lock('import:'.$organization->getKey(), config('octane.max_execution_time', 60) + 1);

View File

@@ -16,6 +16,7 @@ use Illuminate\Support\Str;
use League\Csv\Reader;
use Override;
use Spatie\TemporaryDirectory\TemporaryDirectory;
use ZipArchive;
class SolidtimeImporter extends DefaultImporter
{
@@ -33,10 +34,16 @@ class SolidtimeImporter extends DefaultImporter
$temporaryDirectoryZip = null;
$temporaryDirectory = null;
try {
$zip = new ZipArchive;
$temporaryDirectoryZip = TemporaryDirectory::make();
file_put_contents($temporaryDirectoryZip->path('import.zip'), $data);
$res = $zip->open($temporaryDirectoryZip->path('import.zip'), ZipArchive::RDONLY);
if ($res !== true) {
throw new ImportException('Invalid ZIP, error code: '.$res);
}
$temporaryDirectory = TemporaryDirectory::make();
app(ZipImportHelper::class)->extract($temporaryDirectoryZip->path('import.zip'), $temporaryDirectory->path());
$zip->extractTo($temporaryDirectory->path());
$zip->close();
if (! file_exists($temporaryDirectory->path('meta.json'))) {
throw new ImportException('File "meta.json" missing in ZIP');

View File

@@ -13,6 +13,7 @@ use Illuminate\Support\Str;
use Override;
use Spatie\TemporaryDirectory\TemporaryDirectory;
use ValueError;
use ZipArchive;
class TogglDataImporter extends DefaultImporter
{
@@ -25,10 +26,16 @@ class TogglDataImporter extends DefaultImporter
$temporaryDirectoryZip = null;
$temporaryDirectory = null;
try {
$zip = new ZipArchive;
$temporaryDirectoryZip = TemporaryDirectory::make();
file_put_contents($temporaryDirectoryZip->path('import.zip'), $data);
$res = $zip->open($temporaryDirectoryZip->path('import.zip'), ZipArchive::RDONLY);
if ($res !== true) {
throw new ImportException('Invalid ZIP, error code: '.$res);
}
$temporaryDirectory = TemporaryDirectory::make();
app(ZipImportHelper::class)->extract($temporaryDirectoryZip->path('import.zip'), $temporaryDirectory->path());
$zip->extractTo($temporaryDirectory->path());
$zip->close();
if (! file_exists($temporaryDirectory->path('clients.json'))) {
throw new ImportException('File "clients.json" missing in ZIP');
}

View File

@@ -1,129 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Service\Import\Importers;
use ZipArchive;
/**
* Extracts uploaded ZIP archives with limits on file count, total uncompressed
* size and entry paths, so a small malicious archive can not fill the disk
* (decompression bomb) or write outside the target directory (zip slip).
*/
class ZipImportHelper
{
private const int CHUNK_SIZE = 1024 * 1024;
/**
* @throws ImportException
*/
public function extract(string $zipPath, string $targetPath): void
{
$zip = new ZipArchive;
$res = $zip->open($zipPath, ZipArchive::RDONLY);
if ($res !== true) {
throw new ImportException('Invalid ZIP, error code: '.$res);
}
try {
$maxFiles = (int) config('import.zip_max_files');
$maxUncompressedSize = (int) config('import.zip_max_uncompressed_size');
if ($zip->numFiles > $maxFiles) {
throw new ImportException('ZIP contains too many files, maximum is '.$maxFiles);
}
// Check the sizes declared in the archive before writing anything to disk
$declaredSize = 0;
for ($index = 0; $index < $zip->numFiles; $index++) {
$stat = $zip->statIndex($index);
if ($stat === false) {
throw new ImportException('Invalid ZIP entry');
}
$this->validateEntryName($stat['name']);
$declaredSize += $stat['size'];
if ($declaredSize > $maxUncompressedSize) {
throw new ImportException('ZIP uncompressed size exceeds the maximum of '.$maxUncompressedSize.' bytes');
}
}
// The declared sizes can be forged, so the written bytes are counted as well
$writtenSize = 0;
for ($index = 0; $index < $zip->numFiles; $index++) {
$stat = $zip->statIndex($index);
if ($stat === false) {
throw new ImportException('Invalid ZIP entry');
}
$name = $stat['name'];
$entryPath = $targetPath.DIRECTORY_SEPARATOR.$name;
if (str_ends_with($name, '/')) {
$this->ensureDirectoryExists($entryPath);
continue;
}
$this->ensureDirectoryExists(dirname($entryPath));
$stream = $zip->getStreamIndex($index);
if ($stream === false) {
throw new ImportException('ZIP entry "'.$name.'" can not be read');
}
$target = fopen($entryPath, 'wb');
if ($target === false) {
fclose($stream);
throw new ImportException('ZIP entry "'.$name.'" can not be extracted');
}
try {
while (! feof($stream)) {
$chunk = fread($stream, self::CHUNK_SIZE);
if ($chunk === false) {
throw new ImportException('ZIP entry "'.$name.'" can not be read');
}
$writtenSize += strlen($chunk);
if ($writtenSize > $maxUncompressedSize) {
throw new ImportException('ZIP uncompressed size exceeds the maximum of '.$maxUncompressedSize.' bytes');
}
fwrite($target, $chunk);
}
} finally {
fclose($target);
fclose($stream);
}
}
} finally {
$zip->close();
}
}
/**
* @throws ImportException
*/
private function validateEntryName(string $name): void
{
if ($name === '' || str_contains($name, "\0") || str_contains($name, '\\') || str_starts_with($name, '/')) {
throw new ImportException('ZIP contains an invalid file path: "'.$name.'"');
}
if (preg_match('/^[a-zA-Z]:/', $name) === 1) {
throw new ImportException('ZIP contains an invalid file path: "'.$name.'"');
}
foreach (explode('/', rtrim($name, '/')) as $segment) {
if ($segment === '' || $segment === '..') {
throw new ImportException('ZIP contains an invalid file path: "'.$name.'"');
}
}
}
/**
* @throws ImportException
*/
private function ensureDirectoryExists(string $path): void
{
if (is_dir($path)) {
return;
}
if (! mkdir($path, 0700, true) && ! is_dir($path)) {
throw new ImportException('Directory "'.$path.'" can not be created');
}
}
}

View File

@@ -218,16 +218,7 @@ class MemberService
$placeholderUser = $user->replicate();
$placeholderUser->is_placeholder = true;
// Reset authentication relevant properties on the placeholder user
$placeholderUser->password = null;
$placeholderUser->remember_token = null;
$placeholderUser->two_factor_secret = null;
$placeholderUser->two_factor_recovery_codes = null;
$placeholderUser->two_factor_confirmed_at = null;
$placeholderUser->email_verified_at = null;
$placeholderUser->pending_email = null;
$placeholderUser->current_team_id = null;
$placeholderUser->profile_photo_path = null;
$placeholderUser->current_team_id = $member->organization_id;
$placeholderUser->save();
$member->user()->associate($placeholderUser);

View File

@@ -9,7 +9,7 @@
"ext-zip": "*",
"brick/money": "^0.10.0",
"datomatic/laravel-enum-helper": "^2.0.0",
"dedoc/scramble": "^0.13.44",
"dedoc/scramble": "^0.13.43",
"filament/filament": "^3.2",
"flowframe/laravel-trend": "^0.5.0",
"gotenberg/gotenberg-php": "^2.8",

278
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "6e0e3c62d8a2d3b50c9ee05ebbed36ec",
"content-hash": "c2eea24a697f9af5c71a698761da04d9",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@@ -127,16 +127,16 @@
},
{
"name": "aws/aws-sdk-php",
"version": "3.395.6",
"version": "3.395.1",
"source": {
"type": "git",
"url": "https://github.com/aws/aws-sdk-php.git",
"reference": "f88c98a1d678c8b26dab0385af485eb7e2a22bf6"
"reference": "f5eda89b1f2ecd4aea56eb04d58874bad12ef02e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/f88c98a1d678c8b26dab0385af485eb7e2a22bf6",
"reference": "f88c98a1d678c8b26dab0385af485eb7e2a22bf6",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/f5eda89b1f2ecd4aea56eb04d58874bad12ef02e",
"reference": "f5eda89b1f2ecd4aea56eb04d58874bad12ef02e",
"shasum": ""
},
"require": {
@@ -218,9 +218,9 @@
"support": {
"forum": "https://github.com/aws/aws-sdk-php/discussions",
"issues": "https://github.com/aws/aws-sdk-php/issues",
"source": "https://github.com/aws/aws-sdk-php/tree/3.395.6"
"source": "https://github.com/aws/aws-sdk-php/tree/3.395.1"
},
"time": "2026-09-18T18:54:36+00:00"
"time": "2026-09-13T18:43:19+00:00"
},
{
"name": "bacon/bacon-qr-code",
@@ -1506,16 +1506,16 @@
},
{
"name": "dedoc/scramble",
"version": "v0.13.44",
"version": "0.13.43",
"source": {
"type": "git",
"url": "https://github.com/dedoc/scramble.git",
"reference": "6351072acd060cd0cf185162912ca7b3294d4fa5"
"reference": "2226e6664b88b3af1e5c10693f8e63a07ffd8fc0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dedoc/scramble/zipball/6351072acd060cd0cf185162912ca7b3294d4fa5",
"reference": "6351072acd060cd0cf185162912ca7b3294d4fa5",
"url": "https://api.github.com/repos/dedoc/scramble/zipball/2226e6664b88b3af1e5c10693f8e63a07ffd8fc0",
"reference": "2226e6664b88b3af1e5c10693f8e63a07ffd8fc0",
"shasum": ""
},
"require": {
@@ -1575,7 +1575,7 @@
],
"support": {
"issues": "https://github.com/dedoc/scramble/issues",
"source": "https://github.com/dedoc/scramble/tree/v0.13.44"
"source": "https://github.com/dedoc/scramble/tree/0.13.43"
},
"funding": [
{
@@ -1583,7 +1583,7 @@
"type": "github"
}
],
"time": "2026-09-18T07:56:08+00:00"
"time": "2026-09-08T18:27:41+00:00"
},
{
"name": "defuse/php-encryption",
@@ -1973,25 +1973,27 @@
},
{
"name": "doctrine/lexer",
"version": "3.0.2",
"version": "3.0.1",
"source": {
"type": "git",
"url": "https://github.com/doctrine/lexer.git",
"reference": "e96fe45e92a54233726014a7cc7340abf29bb14c"
"reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/lexer/zipball/e96fe45e92a54233726014a7cc7340abf29bb14c",
"reference": "e96fe45e92a54233726014a7cc7340abf29bb14c",
"url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd",
"reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"doctrine/coding-standard": "^14",
"phpstan/phpstan": "^2",
"phpunit/phpunit": "^10.5.58 || ^12.5.4"
"doctrine/coding-standard": "^12",
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^10.5",
"psalm/plugin-phpunit": "^0.18.3",
"vimeo/psalm": "^5.21"
},
"type": "library",
"autoload": {
@@ -2028,7 +2030,7 @@
],
"support": {
"issues": "https://github.com/doctrine/lexer/issues",
"source": "https://github.com/doctrine/lexer/tree/3.0.2"
"source": "https://github.com/doctrine/lexer/tree/3.0.1"
},
"funding": [
{
@@ -2044,7 +2046,7 @@
"type": "tidelift"
}
],
"time": "2026-06-14T20:44:06+00:00"
"time": "2024-02-05T11:56:58+00:00"
},
{
"name": "dragonmantank/cron-expression",
@@ -2672,23 +2674,23 @@
},
{
"name": "firebase/php-jwt",
"version": "v7.1.1",
"version": "v7.1.0",
"source": {
"type": "git",
"url": "https://github.com/googleapis/php-jwt.git",
"reference": "9bc93bd7e3ee5bead4cd23c365ec12f3c1fb0a6a"
"reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/googleapis/php-jwt/zipball/9bc93bd7e3ee5bead4cd23c365ec12f3c1fb0a6a",
"reference": "9bc93bd7e3ee5bead4cd23c365ec12f3c1fb0a6a",
"url": "https://api.github.com/repos/googleapis/php-jwt/zipball/b374a5d1a4f1f67fadc2165cdb284645945e2fc0",
"reference": "b374a5d1a4f1f67fadc2165cdb284645945e2fc0",
"shasum": ""
},
"require": {
"php": "^8.0"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.4||^8.0",
"guzzlehttp/guzzle": "^7.4",
"phpfastcache/phpfastcache": "^9.2",
"phpseclib/phpseclib": "~3.0",
"phpspec/prophecy-phpunit": "^2.0",
@@ -2732,9 +2734,9 @@
],
"support": {
"issues": "https://github.com/googleapis/php-jwt/issues",
"source": "https://github.com/googleapis/php-jwt/tree/v7.1.1"
"source": "https://github.com/googleapis/php-jwt/tree/v7.1.0"
},
"time": "2026-09-14T17:48:47+00:00"
"time": "2026-06-11T17:54:14+00:00"
},
{
"name": "flowframe/laravel-trend",
@@ -8989,20 +8991,20 @@
},
{
"name": "ramsey/uuid",
"version": "4.9.4",
"version": "4.9.3",
"source": {
"type": "git",
"url": "https://github.com/ramsey/uuid.git",
"reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2"
"reference": "1df15849d00943a67d677dc9cfd80795f038c9f8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ramsey/uuid/zipball/75d73f48d02797c2c285a7e9f348fadc0102ffe2",
"reference": "75d73f48d02797c2c285a7e9f348fadc0102ffe2",
"url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8",
"reference": "1df15849d00943a67d677dc9cfd80795f038c9f8",
"shasum": ""
},
"require": {
"brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0",
"brick/math": ">=0.8.16 <=0.18",
"php": "^8.0",
"ramsey/collection": "^1.2 || ^2.0"
},
@@ -9061,9 +9063,9 @@
],
"support": {
"issues": "https://github.com/ramsey/uuid/issues",
"source": "https://github.com/ramsey/uuid/tree/4.9.4"
"source": "https://github.com/ramsey/uuid/tree/4.9.3"
},
"time": "2026-09-16T11:39:30+00:00"
"time": "2026-06-18T03:57:49+00:00"
},
{
"name": "react/promise",
@@ -9694,16 +9696,16 @@
},
{
"name": "spomky-labs/cbor-php",
"version": "3.4.2",
"version": "3.4.1",
"source": {
"type": "git",
"url": "https://github.com/Spomky-Labs/cbor-php.git",
"reference": "8f5ea00a07ad529d20886505cdbeb2b9ac7bb2d6"
"reference": "995b7747ca48c9cb55a8e644cd37f4f00d806e7d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/8f5ea00a07ad529d20886505cdbeb2b9ac7bb2d6",
"reference": "8f5ea00a07ad529d20886505cdbeb2b9ac7bb2d6",
"url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/995b7747ca48c9cb55a8e644cd37f4f00d806e7d",
"reference": "995b7747ca48c9cb55a8e644cd37f4f00d806e7d",
"shasum": ""
},
"require": {
@@ -9750,7 +9752,7 @@
],
"support": {
"issues": "https://github.com/Spomky-Labs/cbor-php/issues",
"source": "https://github.com/Spomky-Labs/cbor-php/tree/3.4.2"
"source": "https://github.com/Spomky-Labs/cbor-php/tree/3.4.1"
},
"funding": [
{
@@ -9762,7 +9764,7 @@
"type": "patreon"
}
],
"time": "2026-09-15T06:55:29+00:00"
"time": "2026-09-12T18:58:34+00:00"
},
{
"name": "spomky-labs/pki-framework",
@@ -10101,16 +10103,16 @@
},
{
"name": "symfony/console",
"version": "v7.4.19",
"version": "v7.4.18",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
"reference": "3a1973458b153f566a8d7499bc06ece323d60063"
"reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/console/zipball/3a1973458b153f566a8d7499bc06ece323d60063",
"reference": "3a1973458b153f566a8d7499bc06ece323d60063",
"url": "https://api.github.com/repos/symfony/console/zipball/23d6f88a29f6d0eac45bd77d70307adf83ba7ab0",
"reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0",
"shasum": ""
},
"require": {
@@ -10175,7 +10177,7 @@
"terminal"
],
"support": {
"source": "https://github.com/symfony/console/tree/v7.4.19"
"source": "https://github.com/symfony/console/tree/v7.4.18"
},
"funding": [
{
@@ -10195,7 +10197,7 @@
"type": "tidelift"
}
],
"time": "2026-09-13T10:38:41+00:00"
"time": "2026-08-25T14:18:37+00:00"
},
{
"name": "symfony/css-selector",
@@ -10656,16 +10658,16 @@
},
{
"name": "symfony/finder",
"version": "v7.4.19",
"version": "v7.4.17",
"source": {
"type": "git",
"url": "https://github.com/symfony/finder.git",
"reference": "1b900bc6ae5ba60c5eee69c11ba44566190576fe"
"reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/finder/zipball/1b900bc6ae5ba60c5eee69c11ba44566190576fe",
"reference": "1b900bc6ae5ba60c5eee69c11ba44566190576fe",
"url": "https://api.github.com/repos/symfony/finder/zipball/5ce28827081f6d1f0c32eaf3882750f19cb5bbe6",
"reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6",
"shasum": ""
},
"require": {
@@ -10700,7 +10702,7 @@
"description": "Finds files and directories via an intuitive fluent interface",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/finder/tree/v7.4.19"
"source": "https://github.com/symfony/finder/tree/v7.4.17"
},
"funding": [
{
@@ -10720,7 +10722,7 @@
"type": "tidelift"
}
],
"time": "2026-09-10T19:14:10+00:00"
"time": "2026-08-21T12:09:28+00:00"
},
{
"name": "symfony/html-sanitizer",
@@ -10798,16 +10800,16 @@
},
{
"name": "symfony/http-foundation",
"version": "v7.4.19",
"version": "v7.4.18",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-foundation.git",
"reference": "f7b953be7801974a5a0685e1228bbc3b12b35936"
"reference": "d070b716a32fbe3bf04204db0f58ace73b86d133"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/f7b953be7801974a5a0685e1228bbc3b12b35936",
"reference": "f7b953be7801974a5a0685e1228bbc3b12b35936",
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/d070b716a32fbe3bf04204db0f58ace73b86d133",
"reference": "d070b716a32fbe3bf04204db0f58ace73b86d133",
"shasum": ""
},
"require": {
@@ -10856,7 +10858,7 @@
"description": "Defines an object-oriented layer for the HTTP specification",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/http-foundation/tree/v7.4.19"
"source": "https://github.com/symfony/http-foundation/tree/v7.4.18"
},
"funding": [
{
@@ -10876,20 +10878,20 @@
"type": "tidelift"
}
],
"time": "2026-09-14T17:41:17+00:00"
"time": "2026-08-30T20:10:52+00:00"
},
{
"name": "symfony/http-kernel",
"version": "v7.4.19",
"version": "v7.4.18",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-kernel.git",
"reference": "683507424cc84ca85ac1551adb609b6b98216667"
"reference": "275d2d2d24530f2a0eaf17704a3a93860a036351"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/683507424cc84ca85ac1551adb609b6b98216667",
"reference": "683507424cc84ca85ac1551adb609b6b98216667",
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/275d2d2d24530f2a0eaf17704a3a93860a036351",
"reference": "275d2d2d24530f2a0eaf17704a3a93860a036351",
"shasum": ""
},
"require": {
@@ -10975,7 +10977,7 @@
"description": "Provides a structured process for converting a Request into a Response",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/http-kernel/tree/v7.4.19"
"source": "https://github.com/symfony/http-kernel/tree/v7.4.18"
},
"funding": [
{
@@ -10995,20 +10997,20 @@
"type": "tidelift"
}
],
"time": "2026-09-15T07:09:35+00:00"
"time": "2026-08-30T21:24:29+00:00"
},
{
"name": "symfony/mailer",
"version": "v7.4.19",
"version": "v7.4.17",
"source": {
"type": "git",
"url": "https://github.com/symfony/mailer.git",
"reference": "6f6f2441bef07a42d2617b9b3aadf04a321aa514"
"reference": "b17c9bf3a551d5f635638a3b6c05f06c4dc87584"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/mailer/zipball/6f6f2441bef07a42d2617b9b3aadf04a321aa514",
"reference": "6f6f2441bef07a42d2617b9b3aadf04a321aa514",
"url": "https://api.github.com/repos/symfony/mailer/zipball/b17c9bf3a551d5f635638a3b6c05f06c4dc87584",
"reference": "b17c9bf3a551d5f635638a3b6c05f06c4dc87584",
"shasum": ""
},
"require": {
@@ -11059,7 +11061,7 @@
"description": "Helps sending emails",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/mailer/tree/v7.4.19"
"source": "https://github.com/symfony/mailer/tree/v7.4.17"
},
"funding": [
{
@@ -11079,20 +11081,20 @@
"type": "tidelift"
}
],
"time": "2026-09-15T06:01:15+00:00"
"time": "2026-08-21T17:40:08+00:00"
},
{
"name": "symfony/mime",
"version": "v7.4.19",
"version": "v7.4.18",
"source": {
"type": "git",
"url": "https://github.com/symfony/mime.git",
"reference": "f8ac7d0a800bcabbcd1f7de56594cf05e6cd5693"
"reference": "bf328d82105831db3e409195db0540ff57f27c80"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/mime/zipball/f8ac7d0a800bcabbcd1f7de56594cf05e6cd5693",
"reference": "f8ac7d0a800bcabbcd1f7de56594cf05e6cd5693",
"url": "https://api.github.com/repos/symfony/mime/zipball/bf328d82105831db3e409195db0540ff57f27c80",
"reference": "bf328d82105831db3e409195db0540ff57f27c80",
"shasum": ""
},
"require": {
@@ -11148,7 +11150,7 @@
"mime-type"
],
"support": {
"source": "https://github.com/symfony/mime/tree/v7.4.19"
"source": "https://github.com/symfony/mime/tree/v7.4.18"
},
"funding": [
{
@@ -11168,7 +11170,7 @@
"type": "tidelift"
}
],
"time": "2026-09-04T10:45:44+00:00"
"time": "2026-08-22T09:04:42+00:00"
},
{
"name": "symfony/polyfill-ctype",
@@ -12241,16 +12243,16 @@
},
{
"name": "symfony/process",
"version": "v7.4.19",
"version": "v7.4.18",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
"reference": "ed0ae095b86994d370d5791612e55984f15aa30e"
"reference": "058d17fc284cce14efb2385783b55014a461b176"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/process/zipball/ed0ae095b86994d370d5791612e55984f15aa30e",
"reference": "ed0ae095b86994d370d5791612e55984f15aa30e",
"url": "https://api.github.com/repos/symfony/process/zipball/058d17fc284cce14efb2385783b55014a461b176",
"reference": "058d17fc284cce14efb2385783b55014a461b176",
"shasum": ""
},
"require": {
@@ -12282,7 +12284,7 @@
"description": "Executes commands in sub-processes",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/process/tree/v7.4.19"
"source": "https://github.com/symfony/process/tree/v7.4.18"
},
"funding": [
{
@@ -12302,7 +12304,7 @@
"type": "tidelift"
}
],
"time": "2026-09-02T12:38:34+00:00"
"time": "2026-08-21T17:40:08+00:00"
},
{
"name": "symfony/property-access",
@@ -12387,16 +12389,16 @@
},
{
"name": "symfony/property-info",
"version": "v7.4.19",
"version": "v7.4.18",
"source": {
"type": "git",
"url": "https://github.com/symfony/property-info.git",
"reference": "547d2acbe865d511809b96ab653304fd632abbaa"
"reference": "52420d499b03ec20c0d38f0dc2f940bc9c43dc8e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/property-info/zipball/547d2acbe865d511809b96ab653304fd632abbaa",
"reference": "547d2acbe865d511809b96ab653304fd632abbaa",
"url": "https://api.github.com/repos/symfony/property-info/zipball/52420d499b03ec20c0d38f0dc2f940bc9c43dc8e",
"reference": "52420d499b03ec20c0d38f0dc2f940bc9c43dc8e",
"shasum": ""
},
"require": {
@@ -12453,7 +12455,7 @@
"validator"
],
"support": {
"source": "https://github.com/symfony/property-info/tree/v7.4.19"
"source": "https://github.com/symfony/property-info/tree/v7.4.18"
},
"funding": [
{
@@ -12473,7 +12475,7 @@
"type": "tidelift"
}
],
"time": "2026-09-04T10:11:42+00:00"
"time": "2026-08-25T14:18:37+00:00"
},
{
"name": "symfony/psr-http-message-bridge",
@@ -12650,16 +12652,16 @@
},
{
"name": "symfony/serializer",
"version": "v7.4.19",
"version": "v7.4.18",
"source": {
"type": "git",
"url": "https://github.com/symfony/serializer.git",
"reference": "d631b82e0db971444651c4e6a076040d20878caa"
"reference": "1f31d4e902c85b820af5dce27dbe17435524bed0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/serializer/zipball/d631b82e0db971444651c4e6a076040d20878caa",
"reference": "d631b82e0db971444651c4e6a076040d20878caa",
"url": "https://api.github.com/repos/symfony/serializer/zipball/1f31d4e902c85b820af5dce27dbe17435524bed0",
"reference": "1f31d4e902c85b820af5dce27dbe17435524bed0",
"shasum": ""
},
"require": {
@@ -12730,7 +12732,7 @@
"description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/serializer/tree/v7.4.19"
"source": "https://github.com/symfony/serializer/tree/v7.4.18"
},
"funding": [
{
@@ -12750,7 +12752,7 @@
"type": "tidelift"
}
],
"time": "2026-09-08T13:28:43+00:00"
"time": "2026-08-30T00:47:26+00:00"
},
{
"name": "symfony/service-contracts",
@@ -12841,16 +12843,16 @@
},
{
"name": "symfony/string",
"version": "v7.4.19",
"version": "v7.4.15",
"source": {
"type": "git",
"url": "https://github.com/symfony/string.git",
"reference": "02731ce2bd26514bec9efc0baef2cd0b0cf67f46"
"reference": "e394af32256bf9e7bf80849d95e589167c10097b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/string/zipball/02731ce2bd26514bec9efc0baef2cd0b0cf67f46",
"reference": "02731ce2bd26514bec9efc0baef2cd0b0cf67f46",
"url": "https://api.github.com/repos/symfony/string/zipball/e394af32256bf9e7bf80849d95e589167c10097b",
"reference": "e394af32256bf9e7bf80849d95e589167c10097b",
"shasum": ""
},
"require": {
@@ -12908,7 +12910,7 @@
"utf8"
],
"support": {
"source": "https://github.com/symfony/string/tree/v7.4.19"
"source": "https://github.com/symfony/string/tree/v7.4.15"
},
"funding": [
{
@@ -12928,7 +12930,7 @@
"type": "tidelift"
}
],
"time": "2026-09-11T14:46:58+00:00"
"time": "2026-07-28T07:33:02+00:00"
},
{
"name": "symfony/translation",
@@ -13717,36 +13719,32 @@
},
{
"name": "web-auth/cose-lib",
"version": "4.8.2",
"version": "4.7.1",
"source": {
"type": "git",
"url": "https://github.com/web-auth/cose-lib.git",
"reference": "8849e8bf043a2d42d0bec5bda5db2469ad376148"
"reference": "e698b150049141f2f9ec03d2a4d681a2d020ab83"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/web-auth/cose-lib/zipball/8849e8bf043a2d42d0bec5bda5db2469ad376148",
"reference": "8849e8bf043a2d42d0bec5bda5db2469ad376148",
"url": "https://api.github.com/repos/web-auth/cose-lib/zipball/e698b150049141f2f9ec03d2a4d681a2d020ab83",
"reference": "e698b150049141f2f9ec03d2a4d681a2d020ab83",
"shasum": ""
},
"require": {
"brick/math": "^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0",
"brick/math": "^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20",
"ext-json": "*",
"ext-openssl": "*",
"php": ">=8.1",
"spomky-labs/pki-framework": "^1.0"
},
"conflict": {
"spomky-labs/cbor-php": "<3.4.0"
},
"require-dev": {
"spomky-labs/cbor-php": "^3.4"
"spomky-labs/cbor-php": "^3.2.2"
},
"suggest": {
"ext-bcmath": "Recommended: without GMP or BCMath, signing with RSASSA-PSS (PS256/PS384/PS512) blinds its private exponentiation in pure PHP",
"ext-gmp": "Recommended: without GMP or BCMath, signing with RSASSA-PSS (PS256/PS384/PS512) blinds its private exponentiation in pure PHP",
"ext-sodium": "Required by the EdDSA/Ed25519 signature algorithms (-8, -19, -260, -261) and to recompute an OKP public key from its private key",
"spomky-labs/cbor-php": "Required by the RFC 9052 header reader and cryptographic structures. 3.4.0 or later: it ships the six COSE message classes (CBOR\\Tag\\CoseSign1Tag and its siblings) that replace the deprecated Cose\\...Tag classes, and its decoder is what enforces the RFC 9052 label uniqueness and nesting bounds this library relies on"
"ext-bcmath": "For better performance, please install either GMP (recommended) or BCMath extension",
"ext-gmp": "For better performance, please install either GMP (recommended) or BCMath extension",
"spomky-labs/cbor-php": "For COSE Signature support"
},
"type": "library",
"autoload": {
@@ -13776,7 +13774,7 @@
],
"support": {
"issues": "https://github.com/web-auth/cose-lib/issues",
"source": "https://github.com/web-auth/cose-lib/tree/4.8.2"
"source": "https://github.com/web-auth/cose-lib/tree/4.7.1"
},
"funding": [
{
@@ -13788,20 +13786,20 @@
"type": "patreon"
}
],
"time": "2026-09-15T17:30:33+00:00"
"time": "2026-08-30T13:53:57+00:00"
},
{
"name": "web-auth/webauthn-lib",
"version": "5.3.9",
"version": "5.3.8",
"source": {
"type": "git",
"url": "https://github.com/web-auth/webauthn-lib.git",
"reference": "727e378fb7a36c26be5c911e4a0120c146741ce7"
"reference": "85d8ae791c87a34be91a7ff5cdb5606f67dedaab"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/727e378fb7a36c26be5c911e4a0120c146741ce7",
"reference": "727e378fb7a36c26be5c911e4a0120c146741ce7",
"url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/85d8ae791c87a34be91a7ff5cdb5606f67dedaab",
"reference": "85d8ae791c87a34be91a7ff5cdb5606f67dedaab",
"shasum": ""
},
"require": {
@@ -13813,7 +13811,7 @@
"psr/clock": "^1.0",
"psr/event-dispatcher": "^1.0",
"psr/log": "^1.0|^2.0|^3.0",
"spomky-labs/cbor-php": "^3.4",
"spomky-labs/cbor-php": "^3.0",
"spomky-labs/pki-framework": "^1.0",
"symfony/clock": "^6.4|^7.0|^8.0",
"symfony/deprecation-contracts": "^3.2",
@@ -13821,7 +13819,7 @@
"symfony/property-info": "^6.4|^7.0|^8.0",
"symfony/serializer": "^6.4|^7.0|^8.0",
"symfony/uid": "^6.4|^7.0|^8.0",
"web-auth/cose-lib": "^4.8"
"web-auth/cose-lib": "^4.2.3"
},
"suggest": {
"psr/log-implementation": "Recommended to receive logs from the library",
@@ -13862,7 +13860,7 @@
"webauthn"
],
"support": {
"source": "https://github.com/web-auth/webauthn-lib/tree/5.3.9"
"source": "https://github.com/web-auth/webauthn-lib/tree/5.3.8"
},
"funding": [
{
@@ -13874,7 +13872,7 @@
"type": "patreon"
}
],
"time": "2026-09-10T21:10:45+00:00"
"time": "2026-08-30T14:20:17+00:00"
},
{
"name": "webmozart/assert",
@@ -14366,16 +14364,16 @@
},
{
"name": "filp/whoops",
"version": "2.18.5",
"version": "2.18.4",
"source": {
"type": "git",
"url": "https://github.com/filp/whoops.git",
"reference": "baec851ad6ae00db8bd0fdd30d208afc1b71c56e"
"reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/filp/whoops/zipball/baec851ad6ae00db8bd0fdd30d208afc1b71c56e",
"reference": "baec851ad6ae00db8bd0fdd30d208afc1b71c56e",
"url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d",
"reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d",
"shasum": ""
},
"require": {
@@ -14384,8 +14382,8 @@
},
"require-dev": {
"mockery/mockery": "^1.0",
"phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3 || ^10.5.58",
"symfony/var-dumper": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0"
"phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3",
"symfony/var-dumper": "^4.0 || ^5.0"
},
"suggest": {
"symfony/var-dumper": "Pretty print complex values better with var-dumper available",
@@ -14425,7 +14423,7 @@
],
"support": {
"issues": "https://github.com/filp/whoops/issues",
"source": "https://github.com/filp/whoops/tree/2.18.5"
"source": "https://github.com/filp/whoops/tree/2.18.4"
},
"funding": [
{
@@ -14433,7 +14431,7 @@
"type": "github"
}
],
"time": "2026-09-18T12:00:00+00:00"
"time": "2025-08-08T12:00:00+00:00"
},
{
"name": "fumeapp/modeltyper",
@@ -14657,16 +14655,16 @@
},
{
"name": "larastan/larastan",
"version": "v3.12.1",
"version": "v3.12.0",
"source": {
"type": "git",
"url": "https://github.com/larastan/larastan.git",
"reference": "168fd3b955e723c51dbf81709849b0c3ac4ac5f6"
"reference": "ab26fdf90d84877061388109e1e251f67ca3ff7a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/larastan/larastan/zipball/168fd3b955e723c51dbf81709849b0c3ac4ac5f6",
"reference": "168fd3b955e723c51dbf81709849b0c3ac4ac5f6",
"url": "https://api.github.com/repos/larastan/larastan/zipball/ab26fdf90d84877061388109e1e251f67ca3ff7a",
"reference": "ab26fdf90d84877061388109e1e251f67ca3ff7a",
"shasum": ""
},
"require": {
@@ -14679,7 +14677,7 @@
"illuminate/pipeline": "^11.44.2 || ^12.4.1 || ^13",
"illuminate/support": "^11.44.2 || ^12.4.1 || ^13",
"php": "^8.2",
"phpstan/phpstan": "^2.2.14"
"phpstan/phpstan": "^2.2.2"
},
"require-dev": {
"doctrine/coding-standard": "^14",
@@ -14734,7 +14732,7 @@
],
"support": {
"issues": "https://github.com/larastan/larastan/issues",
"source": "https://github.com/larastan/larastan/tree/v3.12.1"
"source": "https://github.com/larastan/larastan/tree/v3.12.0"
},
"funding": [
{
@@ -14742,7 +14740,7 @@
"type": "github"
}
],
"time": "2026-09-13T15:17:35+00:00"
"time": "2026-09-10T09:06:54+00:00"
},
{
"name": "laravel/pint",

View File

@@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
return [
/*
|--------------------------------------------------------------------------
| Import payload limit
|--------------------------------------------------------------------------
|
| Maximum length of the base64 encoded "data" field of an import request in
| bytes. Requests with a larger payload are rejected with a validation error.
|
*/
'max_data_size' => (int) (env('IMPORT_MAX_DATA_SIZE') ?: 50 * 1024 * 1024),
/*
|--------------------------------------------------------------------------
| ZIP extraction limits
|--------------------------------------------------------------------------
|
| Limits applied to ZIP based importers before and during extraction to
| protect the instance against decompression bombs. The uncompressed size
| is the sum of all files in the archive in bytes.
|
*/
'zip_max_files' => (int) (env('IMPORT_ZIP_MAX_FILES') ?: 100),
'zip_max_uncompressed_size' => (int) (env('IMPORT_ZIP_MAX_UNCOMPRESSED_SIZE') ?: 500 * 1024 * 1024),
];

View File

@@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Placeholder users used to be created as a full copy of the user they were made from,
* which included the credentials and the account state of that user. A placeholder is a
* stand-in for a person in one organization, not an account, and the row shares the email
* address with the real account, so these values are removed from the placeholders that
* already exist. The organization a placeholder belongs to is recorded on its member row.
*/
public function up(): void
{
DB::table('users')
->where('is_placeholder', '=', true)
->where(function (Builder $builder): void {
$builder->whereNotNull('password')
->orWhereNotNull('remember_token')
->orWhereNotNull('two_factor_secret')
->orWhereNotNull('two_factor_recovery_codes')
->orWhereNotNull('two_factor_confirmed_at')
->orWhereNotNull('email_verified_at')
->orWhereNotNull('pending_email')
->orWhereNotNull('current_team_id')
->orWhereNotNull('profile_photo_path');
})
->update([
'password' => null,
'remember_token' => null,
'two_factor_secret' => null,
'two_factor_recovery_codes' => null,
'two_factor_confirmed_at' => null,
'email_verified_at' => null,
'pending_email' => null,
'current_team_id' => null,
'profile_photo_path' => null,
]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
//
}
};

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia';
import { computed, ref, watch } from 'vue';
import { computed, ref } from 'vue';
import { api } from '@/packages/api/src';
import type { TimeEntry } from '@/packages/api/src';
import dayjs, { Dayjs } from 'dayjs';
@@ -57,6 +57,7 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
const currentTimeEntry = ref<TimeEntry>({ ...emptyTimeEntry });
const { handleApiRequestNotifications } = useNotificationsStore();
const queryClient = useQueryClient();
useLocalStorage('solidtime/current-time-entry', currentTimeEntry, {
deep: true,
});
@@ -88,12 +89,23 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
try {
const timeEntriesResponse = await api.getMyActiveTimeEntry({});
if (timeEntriesResponse?.data) {
currentTimeEntry.value = timeEntriesResponse.data;
} else if (currentTimeEntry.value.id !== '') {
// No active time entry on server
// Only reset if we had a previously started timer (has an ID)
// Don't reset if user is preparing a new time entry (no ID yet)
currentTimeEntry.value = { ...emptyTimeEntry };
if (timeEntriesResponse.data) {
currentTimeEntry.value = timeEntriesResponse.data;
if (
currentTimeEntry.value.start !== '' &&
currentTimeEntry.value.end === null
) {
startLiveTimer();
}
} else {
// No active time entry on server
// Only reset if we had a previously started timer (has an ID)
// Don't reset if user is preparing a new time entry (no ID yet)
if (currentTimeEntry.value.id !== '') {
currentTimeEntry.value = { ...emptyTimeEntry };
stopLiveTimer();
}
}
}
} catch {
// API error (e.g., 404 when no active time entry)
@@ -101,6 +113,7 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
// Don't reset if user is preparing a new time entry (no ID yet)
if (currentTimeEntry.value.id !== '') {
currentTimeEntry.value = { ...emptyTimeEntry };
stopLiveTimer();
}
}
} else {
@@ -281,18 +294,6 @@ export const useCurrentTimeEntryStore = defineStore('currentTimeEntry', () => {
return isActive.value && currentTimeEntry.value.type === 'break';
});
watch(
isActive,
(active) => {
if (active) {
startLiveTimer();
} else {
stopLiveTimer();
}
},
{ immediate: true }
);
async function setActiveState(newState: boolean) {
if (newState) {
startLiveTimer();

View File

@@ -6,7 +6,6 @@ namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class PasswordConfirmationTest extends TestCase
@@ -44,43 +43,4 @@ class PasswordConfirmationTest extends TestCase
$response->assertSessionHasErrors();
}
public function test_password_can_be_confirmed_if_a_placeholder_user_with_the_same_email_exists(): void
{
// Arrange
// Placeholders created by an import have no password at all. The placeholder is created
// first so that it would be returned by an unordered lookup by email.
$email = 'shared@example.com';
User::factory()->placeholder()->create(['email' => $email, 'password' => null]);
$user = User::factory()->create(['email' => $email, 'password' => Hash::make('secret-password')]);
// Act
$response = $this->actingAs($user)->post('/user/confirm-password', [
'password' => 'secret-password',
]);
// Assert
$response->assertRedirect();
$response->assertSessionHasNoErrors();
$this->assertTrue($this->app['session']->has('auth.password_confirmed_at'));
}
public function test_password_confirmation_ignores_the_password_of_a_placeholder_user_with_the_same_email(): void
{
// Arrange
// Placeholders created by removing a member copy the password hash as of the removal,
// so the placeholder holds a password that the real user has since replaced.
$email = 'shared@example.com';
User::factory()->placeholder()->create(['email' => $email, 'password' => Hash::make('outdated-password')]);
$user = User::factory()->create(['email' => $email, 'password' => Hash::make('current-password')]);
// Act
$response = $this->actingAs($user)->post('/user/confirm-password', [
'password' => 'outdated-password',
]);
// Assert
$response->assertSessionHasErrors();
$this->assertFalse($this->app['session']->has('auth.password_confirmed_at'));
}
}

View File

@@ -7,7 +7,6 @@ namespace Tests\Feature;
use App\Models\User;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
use Laravel\Fortify\Features;
use Tests\TestCase;
@@ -94,62 +93,4 @@ class PasswordResetTest extends TestCase
return true;
});
}
public function test_password_reset_targets_the_real_user_when_a_placeholder_user_with_the_same_email_exists(): void
{
Notification::fake();
// The placeholder is created first so that it would be returned by an unordered lookup by email
$email = 'shared@example.com';
$placeholder = User::factory()->placeholder()->create(['email' => $email]);
$user = User::factory()->create(['email' => $email]);
$placeholderPasswordBefore = $placeholder->password;
$response = $this->post('/forgot-password', [
'email' => $email,
]);
$response->assertSessionHasNoErrors();
Notification::assertNotSentTo($placeholder, ResetPassword::class);
Notification::assertSentTo($user, ResetPassword::class, function (ResetPassword $notification) use ($email) {
$response = $this->post('/reset-password', [
'token' => $notification->token,
'email' => $email,
'password' => 'new-password-123',
'password_confirmation' => 'new-password-123',
]);
$response->assertSessionHasNoErrors();
return true;
});
$placeholder->refresh();
$user->refresh();
$this->assertSame($placeholderPasswordBefore, $placeholder->password);
$this->assertTrue(Hash::check('new-password-123', $user->password));
$response = $this->post('/login', [
'email' => $email,
'password' => 'new-password-123',
]);
$response->assertSessionHasNoErrors();
$this->assertAuthenticatedAs($user);
}
public function test_password_reset_link_is_not_sent_if_only_a_placeholder_user_with_the_email_exists(): void
{
Notification::fake();
$placeholder = User::factory()->placeholder()->create();
$response = $this->post('/forgot-password', [
'email' => $placeholder->email,
]);
$response->assertSessionHasErrors('email');
Notification::assertNothingSent();
}
}

View File

@@ -1,90 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Auth;
use App\Auth\ActiveUserProvider;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use PHPUnit\Framework\Attributes\CoversClass;
use Tests\TestCaseWithDatabase;
#[CoversClass(ActiveUserProvider::class)]
class ActiveUserProviderTest extends TestCaseWithDatabase
{
public function test_password_broker_uses_the_active_user_provider(): void
{
// Act
$brokerProvider = Auth::createUserProvider(config('auth.passwords.users.provider'));
// Assert
$this->assertInstanceOf(ActiveUserProvider::class, $brokerProvider);
}
public function test_api_guard_uses_the_active_user_provider(): void
{
// Act
$guardProvider = Auth::createUserProvider(config('auth.guards.api.provider'));
// Assert
$this->assertInstanceOf(ActiveUserProvider::class, $guardProvider);
}
public function test_web_guard_uses_the_active_user_provider(): void
{
// Act
$guardProvider = Auth::createUserProvider(config('auth.guards.web.provider'));
// Assert
$this->assertInstanceOf(ActiveUserProvider::class, $guardProvider);
}
public function test_retrieve_by_credentials_ignores_placeholder_users_with_the_same_email(): void
{
// Arrange
$email = 'shared@example.com';
$placeholder = User::factory()->placeholder()->create(['email' => $email]);
$user = User::factory()->create(['email' => $email]);
$provider = Auth::createUserProvider('users');
// Act
$result = $provider->retrieveByCredentials(['email' => $email]);
// Assert
$this->assertInstanceOf(User::class, $result);
$this->assertTrue($user->is($result));
$this->assertFalse($placeholder->is($result));
}
public function test_retrieve_by_credentials_returns_null_if_only_a_placeholder_user_exists(): void
{
// Arrange
$email = 'placeholder-only@example.com';
User::factory()->placeholder()->create(['email' => $email]);
$provider = Auth::createUserProvider('users');
// Act
$result = $provider->retrieveByCredentials(['email' => $email]);
// Assert
$this->assertNull($result);
}
public function test_retrieve_by_id_returns_null_for_placeholder_users(): void
{
// Arrange
$placeholder = User::factory()->placeholder()->create();
$user = User::factory()->create();
$provider = Auth::createUserProvider('users');
// Act
$placeholderResult = $provider->retrieveById($placeholder->getKey());
$userResult = $provider->retrieveById($user->getKey());
// Assert
$this->assertNull($placeholderResult);
$this->assertInstanceOf(User::class, $userResult);
$this->assertTrue($user->is($userResult));
}
}

View File

@@ -97,29 +97,6 @@ class ImportEndpointTest extends ApiEndpointTestAbstract
]);
}
public function test_import_fails_if_data_exceeds_maximum_size(): void
{
// Arrange
config(['import.max_data_size' => 16]);
$user = $this->createUserWithPermission([
'import',
]);
$this->mock(ImportService::class, function (MockInterface $mock): void {
$mock->shouldNotReceive('import');
});
Passport::actingAs($user->user);
// Act
$response = $this->postJson(route('api.v1.import.import', ['organization' => $user->organization->getKey()]), [
'type' => 'toggl_time_entries',
'data' => base64_encode(str_repeat('a', 15)),
]);
// Assert
$response->assertStatus(422);
$response->assertJsonValidationErrors(['data']);
}
public function test_import_return_error_message_if_import_fails(): void
{
// Arrange

View File

@@ -105,9 +105,6 @@ class OrganizationInvitationEndpointTest extends EndpointTestAbstract
$this->assertDatabaseMissing(OrganizationInvitation::class, [
'id' => $invitation->getKey(),
]);
// Joining sets the organization as the current one for the user, independently of the
// placeholders that were merged into them
$this->assertSame($user->organization->getKey(), $user2->user->fresh()->current_team_id);
}
public function test_accepting_invitation_while_logged_out_redirects_to_login(): void

View File

@@ -53,23 +53,6 @@ class UserModelTest extends ModelTestAbstract
$this->assertTrue($canAccess);
}
public function test_placeholder_user_with_a_super_admin_email_can_not_access_admin_panel(): void
{
// Arrange
Config::set('auth.super_admins', ['some@email.test', 'other@email.test']);
$user = User::factory()->placeholder()->create([
'email' => 'some@email.test',
]);
$panelProvider = new AdminPanelProvider(app());
$mainPanel = $panelProvider->panel(Panel::make());
// Act
$canAccess = $user->canAccessPanel($mainPanel);
// Assert
$this->assertFalse($canAccess);
}
public function test_scope_belongs_to_organization_returns_only_users_of_organization_including_owners(): void
{
// Arrange

View File

@@ -412,11 +412,10 @@ class DeletionServiceTest extends TestCaseWithDatabase
$this->assertDatabaseHas(Organization::class, [
'id' => $organizationOfA->getKey(),
]);
// The placeholder user should exist and must not reference the deleted organization,
// which is what caused the foreign key violation in #989
// The placeholder user should exist with current_team_id set to the org where they are a placeholder
$placeholderUser = User::query()->where('is_placeholder', true)->first();
$this->assertNotNull($placeholderUser);
$this->assertNull($placeholderUser->current_team_id);
$this->assertSame($organizationOfA->getKey(), $placeholderUser->current_team_id);
$this->assertDatabaseHas(Member::class, [
'id' => $memberBInOrgA->getKey(),
'user_id' => $placeholderUser->getKey(),

View File

@@ -41,7 +41,6 @@ class ImportServiceTest extends TestCase
$this->assertSame(1, $report->usersCreated);
$this->assertSame(2, $report->projectsCreated);
$this->assertSame(1, $report->clientsCreated);
Storage::disk(config('filesystems.default'))->assertDirectoryEmpty('import');
}
public function test_import_releases_lock_if_an_exception_happens_during_the_import(): void

View File

@@ -42,31 +42,6 @@ class SolidtimeImporterTest extends ImporterTestAbstract
$this->fail();
}
public function test_import_throws_exception_if_zip_exceeds_uncompressed_size_limit(): void
{
// Arrange
config(['import.zip_max_uncompressed_size' => 10]);
$zipPath = $this->createTestZip('solidtime_import_test_1');
$timezone = 'Europe/Vienna';
$organization = Organization::factory()->create();
$importer = new SolidtimeImporter;
$importer->init($organization);
$data = file_get_contents($zipPath);
// Act
try {
$importer->importData($data, $timezone);
} catch (Exception $e) {
// Assert
$this->assertInstanceOf(ImportException::class, $e);
$this->assertSame('ZIP uncompressed size exceeds the maximum of 10 bytes', $e->getMessage());
$this->assertSame(0, $importer->getReport()->timeEntriesCreated);
return;
}
$this->fail();
}
public function test_import_of_test_file_succeeds(): void
{
// Arrange

View File

@@ -39,31 +39,6 @@ class TogglDataImporterTest extends ImporterTestAbstract
$this->fail();
}
public function test_import_throws_exception_if_zip_contains_too_many_files(): void
{
// Arrange
config(['import.zip_max_files' => 1]);
$zipPath = $this->createTestZip('toggl_data_import_test_1');
$timezone = 'Europe/Vienna';
$organization = Organization::factory()->create();
$importer = new TogglDataImporter;
$importer->init($organization);
$data = file_get_contents($zipPath);
// Act
try {
$importer->importData($data, $timezone);
} catch (Exception $e) {
// Assert
$this->assertInstanceOf(ImportException::class, $e);
$this->assertSame('ZIP contains too many files, maximum is 1', $e->getMessage());
$this->assertSame(0, $importer->getReport()->projectsCreated);
return;
}
$this->fail();
}
public function test_import_of_test_file_succeeds(): void
{
// Arrange

View File

@@ -1,254 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Service\Import\Importers;
use App\Service\Import\Importers\ImportException;
use App\Service\Import\Importers\ZipImportHelper;
use PHPUnit\Framework\Attributes\CoversClass;
use Spatie\TemporaryDirectory\TemporaryDirectory;
use Tests\TestCase;
use ZipArchive;
#[CoversClass(ZipImportHelper::class)]
class ZipImportHelperTest extends TestCase
{
private TemporaryDirectory $sourceDirectory;
private TemporaryDirectory $targetDirectory;
protected function setUp(): void
{
parent::setUp();
$this->sourceDirectory = TemporaryDirectory::make();
$this->targetDirectory = TemporaryDirectory::make();
}
protected function tearDown(): void
{
$this->sourceDirectory->delete();
$this->targetDirectory->delete();
parent::tearDown();
}
/**
* @param array<string, string> $files
*/
private function createZip(array $files): string
{
$zipPath = $this->sourceDirectory->path('test.zip');
$zip = new ZipArchive;
$zip->open($zipPath, ZipArchive::CREATE);
foreach ($files as $name => $content) {
$zip->addFromString($name, $content);
}
$zip->close();
return $zipPath;
}
private function assertNothingExtracted(): void
{
$this->assertSame([], array_values(array_diff(scandir($this->targetDirectory->path()), ['.', '..'])));
}
public function test_extract_extracts_files_and_nested_directories(): void
{
// Arrange
$zipPath = $this->createZip([
'meta.json' => '{"version":"1.0"}',
'nested/dir/file.csv' => 'a,b',
]);
// Act
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
// Assert
$this->assertSame('{"version":"1.0"}', file_get_contents($this->targetDirectory->path('meta.json')));
$this->assertSame('a,b', file_get_contents($this->targetDirectory->path('nested/dir/file.csv')));
}
public function test_extract_throws_exception_if_file_is_not_a_zip(): void
{
// Arrange
$path = $this->sourceDirectory->path('not-a-zip.txt');
file_put_contents($path, 'not a zip');
// Act
try {
app(ZipImportHelper::class)->extract($path, $this->targetDirectory->path());
} catch (ImportException $e) {
// Assert
$this->assertSame('Invalid ZIP, error code: 19', $e->getMessage());
$this->assertNothingExtracted();
return;
}
$this->fail();
}
public function test_extract_throws_exception_if_zip_contains_too_many_files(): void
{
// Arrange
config(['import.zip_max_files' => 2]);
$zipPath = $this->createZip([
'a.txt' => 'a',
'b.txt' => 'b',
'c.txt' => 'c',
]);
// Act
try {
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
} catch (ImportException $e) {
// Assert
$this->assertSame('ZIP contains too many files, maximum is 2', $e->getMessage());
$this->assertNothingExtracted();
return;
}
$this->fail();
}
public function test_extract_throws_exception_before_writing_if_declared_uncompressed_size_exceeds_limit(): void
{
// Arrange
config(['import.zip_max_uncompressed_size' => 100]);
$zipPath = $this->createZip([
'a.txt' => str_repeat('a', 60),
'b.txt' => str_repeat('b', 60),
]);
// Act
try {
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
} catch (ImportException $e) {
// Assert
$this->assertSame('ZIP uncompressed size exceeds the maximum of 100 bytes', $e->getMessage());
$this->assertNothingExtracted();
return;
}
$this->fail();
}
public function test_extract_throws_exception_if_actual_uncompressed_size_exceeds_limit_despite_forged_headers(): void
{
// Arrange
config(['import.zip_max_uncompressed_size' => 1000]);
$zipPath = $this->createZip([
'bomb.bin' => str_repeat("\0", 100000),
]);
// Forge the uncompressed size in the local file header (offset 22) and central directory header (offset 24)
$content = file_get_contents($zipPath);
$forgedSize = pack('V', 10);
$localHeaderOffset = strpos($content, "PK\x03\x04");
$centralHeaderOffset = strpos($content, "PK\x01\x02");
$this->assertNotFalse($localHeaderOffset);
$this->assertNotFalse($centralHeaderOffset);
$content = substr_replace($content, $forgedSize, $localHeaderOffset + 22, 4);
$content = substr_replace($content, $forgedSize, $centralHeaderOffset + 24, 4);
file_put_contents($zipPath, $content);
$zip = new ZipArchive;
$this->assertTrue($zip->open($zipPath, ZipArchive::RDONLY));
$this->assertSame(10, $zip->statIndex(0)['size']);
$zip->close();
// Act
try {
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
} catch (ImportException $e) {
// Assert
$this->assertSame('ZIP uncompressed size exceeds the maximum of 1000 bytes', $e->getMessage());
$extracted = $this->targetDirectory->path('bomb.bin');
if (file_exists($extracted)) {
$this->assertLessThanOrEqual(1000, filesize($extracted));
}
return;
}
$this->fail();
}
public function test_extract_throws_exception_if_zip_contains_path_traversal(): void
{
// Arrange
$zipPath = $this->createZip([
'../evil.txt' => 'evil',
]);
// Act
try {
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
} catch (ImportException $e) {
// Assert
$this->assertSame('ZIP contains an invalid file path: "../evil.txt"', $e->getMessage());
$this->assertFileDoesNotExist(dirname($this->targetDirectory->path()).'/evil.txt');
$this->assertNothingExtracted();
return;
}
$this->fail();
}
public function test_extract_throws_exception_if_zip_contains_nested_path_traversal(): void
{
// Arrange
$zipPath = $this->createZip([
'sub/../../evil.txt' => 'evil',
]);
// Act
try {
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
} catch (ImportException $e) {
// Assert
$this->assertSame('ZIP contains an invalid file path: "sub/../../evil.txt"', $e->getMessage());
$this->assertNothingExtracted();
return;
}
$this->fail();
}
public function test_extract_throws_exception_if_zip_contains_absolute_path(): void
{
// Arrange
$zipPath = $this->createZip([
'/tmp/evil.txt' => 'evil',
]);
// Act
try {
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
} catch (ImportException $e) {
// Assert
$this->assertSame('ZIP contains an invalid file path: "/tmp/evil.txt"', $e->getMessage());
$this->assertNothingExtracted();
return;
}
$this->fail();
}
public function test_extract_throws_exception_if_zip_contains_backslash_path(): void
{
// Arrange
$zipPath = $this->createZip([
'..\\evil.txt' => 'evil',
]);
// Act
try {
app(ZipImportHelper::class)->extract($zipPath, $this->targetDirectory->path());
} catch (ImportException $e) {
// Assert
$this->assertSame('ZIP contains an invalid file path: "..\\evil.txt"', $e->getMessage());
$this->assertNothingExtracted();
return;
}
$this->fail();
}
}

View File

@@ -13,7 +13,6 @@ use App\Models\TimeEntry;
use App\Models\User;
use App\Service\MemberService;
use App\Service\UserService;
use Illuminate\Support\Facades\Hash;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\CoversClass;
use Tests\TestCaseWithDatabase;
@@ -65,48 +64,6 @@ class MemberServiceTest extends TestCaseWithDatabase
$this->assertSame(Role::Admin->value, $oldOwnerMember->refresh()->role);
}
public function test_make_member_to_placeholder_does_not_copy_the_credentials_and_account_state_of_the_user(): void
{
// Arrange
$user = User::factory()->create([
'password' => Hash::make('secret-password'),
'remember_token' => 'remember-me-token',
'two_factor_secret' => 'two-factor-secret',
'two_factor_recovery_codes' => 'two-factor-recovery-codes',
'two_factor_confirmed_at' => '2026-09-16 10:00:00',
'email_verified_at' => '2026-09-16 09:00:00',
'pending_email' => 'pending@example.com',
'profile_photo_path' => 'profile-photos/photo.png',
]);
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->forUser($user)->role(Role::Employee)->create();
// Act
$this->memberService->makeMemberToPlaceholder($member);
// Assert
$member->refresh();
$placeholderUser = $member->user;
$this->assertTrue($placeholderUser->is_placeholder);
$this->assertSame($user->email, $placeholderUser->email);
$this->assertNull($placeholderUser->password);
$this->assertNull($placeholderUser->remember_token);
$this->assertNull($placeholderUser->two_factor_secret);
$this->assertNull($placeholderUser->two_factor_recovery_codes);
$this->assertNull($placeholderUser->two_factor_confirmed_at);
$this->assertNull($placeholderUser->email_verified_at);
$this->assertNull($placeholderUser->pending_email);
$this->assertNull($placeholderUser->current_team_id);
$this->assertNull($placeholderUser->profile_photo_path);
// the user the placeholder was created from keeps their own credentials and state
$user->refresh();
$this->assertTrue(Hash::check('secret-password', (string) $user->password));
$this->assertSame('two-factor-secret', $user->two_factor_secret);
$this->assertNotNull($user->email_verified_at);
$this->assertSame('pending@example.com', $user->pending_email);
$this->assertSame('profile-photos/photo.png', $user->profile_photo_path);
}
public function test_make_member_to_placeholder_creates_new_user_based_on_member_and_changes_member_to_placeholder(): void
{
// Arrange