From c5b7f726b06a9b708e2869081747ffa65cb1156e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:30:14 +0200 Subject: [PATCH 01/53] fix(security): redact dotted GitHub tokens and contain wide data tables --- bootstrap/helpers/remoteProcess.php | 2 +- resources/css/app.css | 8 ++++++++ tests/Feature/StandardTableComponentsTest.php | 6 ++++++ tests/Unit/SanitizeLogsForExportTest.php | 4 ++-- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index 982dda5511..241368d388 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -346,7 +346,7 @@ function remove_iip($text) $text = preg_replace('/Bearer\s+[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+/i', 'Bearer '.REDACTED, $text); // GitHub tokens (ghp_ = personal, gho_ = OAuth, ghu_ = user-to-server, ghs_ = server-to-server, ghr_ = refresh) - $text = preg_replace('/\b(gh[pousr]_[A-Za-z0-9_]{36,})\b/', REDACTED, $text); + $text = preg_replace('/\bgh[pousr]_[A-Za-z0-9.\-_]{36,}(?![A-Za-z0-9.\-_])/', REDACTED, $text); // GitLab tokens (glpat- = personal access token, glcbt- = CI build token, glrt- = runner token) $text = preg_replace('/\b(gl(?:pat|cbt|rt)-[A-Za-z0-9\-_]{20,})\b/', REDACTED, $text); diff --git a/resources/css/app.css b/resources/css/app.css index 30ccf4ac02..b1ae74c09a 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -2152,6 +2152,14 @@ input[type="search"]::-webkit-search-results-decoration { } /* Data table (layer-card body, full-bleed) */ +.data-table { + min-width: 0; + max-width: 100%; + overflow-x: auto; + overscroll-behavior-x: contain; + -webkit-overflow-scrolling: touch; +} + .data-table-header { display: grid; align-items: center; diff --git a/tests/Feature/StandardTableComponentsTest.php b/tests/Feature/StandardTableComponentsTest.php index 9741268bc7..30437c05b5 100644 --- a/tests/Feature/StandardTableComponentsTest.php +++ b/tests/Feature/StandardTableComponentsTest.php @@ -3,6 +3,12 @@ use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\File; +it('contains wide data tables without overflowing their layout', function () { + $css = file_get_contents(resource_path('css/app.css')); + + expect($css)->toContain(".data-table {\n min-width: 0;\n max-width: 100%;\n overflow-x: auto;"); +}); + it('renders the standard table toolbar controls', function () { $html = Blade::render(<<<'BLADE' diff --git a/tests/Unit/SanitizeLogsForExportTest.php b/tests/Unit/SanitizeLogsForExportTest.php index 285230ea4a..88c5950896 100644 --- a/tests/Unit/SanitizeLogsForExportTest.php +++ b/tests/Unit/SanitizeLogsForExportTest.php @@ -108,14 +108,14 @@ it('removes GitHub tokens', function () { 'gho_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789' => 'gho_ OAuth token', 'ghu_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789' => 'ghu_ user-to-server token', 'ghs_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789' => 'ghs_ server-to-server token', + 'ghs_eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIxMjM0NTY3OCIsInN1YiI6Imluc3RhbGxhdGlvbi10b2tlbiJ9.signature-with_dots-and-hyphens' => 'ghs_ stateless server-to-server token', 'ghr_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789' => 'ghr_ refresh token', ]; foreach ($testCases as $token => $description) { $input = "Token: {$token}"; $result = sanitizeLogsForExport($input); - expect($result)->not->toContain($token, "Failed to redact {$description}"); - expect($result)->toContain(REDACTED); + expect($result)->toBe('Token: '.REDACTED, "Failed to redact {$description}"); } }); From ac4f5af7c0dc5c58f9c5326c88cc55883dbb44df Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:18:59 +0200 Subject: [PATCH 02/53] fix(teams): allow deletion with unused private keys (#11499) --- app/Models/Team.php | 19 +++++++++---- .../views/livewire/team/danger-zone.blade.php | 27 ++++++++++++++++--- tests/Feature/Team/TeamDeletionTest.php | 26 ++++++++++++++++++ 3 files changed, 64 insertions(+), 8 deletions(-) diff --git a/app/Models/Team.php b/app/Models/Team.php index 15085203aa..d5957af5ab 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -275,13 +275,22 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen return $this->hasMany(TeamInvitation::class); } - public function isEmpty() + /** + * @return array + */ + public function deletionBlockers(): array { - if ($this->projects()->count() === 0 && $this->servers()->count() === 0 && $this->privateKeys()->count() === 0 && $this->sources()->count() === 0) { - return true; - } + return array_filter([ + 'projects' => $this->projects()->count(), + 'servers' => $this->servers()->count(), + 'sources' => GithubApp::query()->where('team_id', $this->id)->count() + + GitlabApp::query()->where('team_id', $this->id)->count(), + ]); + } - return false; + public function isEmpty(): bool + { + return $this->deletionBlockers() === []; } public function projects() diff --git a/resources/views/livewire/team/danger-zone.blade.php b/resources/views/livewire/team/danger-zone.blade.php index 8d0c022b12..3d8d78a911 100644 --- a/resources/views/livewire/team/danger-zone.blade.php +++ b/resources/views/livewire/team/danger-zone.blade.php @@ -1,4 +1,12 @@
+ @php + $deletionBlockers = currentTeam()->deletionBlockers(); + $blockerDetails = [ + 'projects' => ['label' => 'project', 'route' => 'project.index'], + 'servers' => ['label' => 'server', 'route' => 'server.index'], + 'sources' => ['label' => 'Git source', 'route' => 'source.all'], + ]; + @endphp Team Danger Zone | Coolify @@ -34,7 +42,7 @@ {{ wireNavigate() }} href="{{ route('subscription.show') }}">subscription before deleting this team.

- @elseif(currentTeam()->isEmpty()) + @elseif($deletionBlockers === [])

Permanently delete {{ currentTeam()->name }} from Coolify. This action cannot be undone. @@ -45,7 +53,20 @@ @else

- Remove or move every resource owned by this team before deleting it. + This team still owns: +

+ +

+ Remove or move these resources before deleting the team.

@endif
@@ -57,7 +78,7 @@ auth()->user()->teams()->count() > 1 && !auth()->user()->currentTeam()->personal_team && !currentTeam()->subscription && - currentTeam()->isEmpty()) + $deletionBlockers === []) assertSuccessful(); }); +test('unused private keys do not block team deletion', function () { + $privateKey = PrivateKey::factory()->create([ + 'team_id' => $this->teamToDelete->id, + 'description' => 'Created by Coolify', + ]); + + expect($this->teamToDelete->isEmpty())->toBeTrue(); + + app(DeleteTeam::class)->handle($this->teamToDelete, $this->owner); + + expect(Team::find($this->teamToDelete->id))->toBeNull() + ->and(PrivateKey::find($privateKey->id))->toBeNull(); +}); + +test('the danger zone names and links blocking resource types', function () { + Project::factory()->count(2)->create(['team_id' => $this->teamToDelete->id]); + + $this->actingAs($this->owner); + session(['currentTeam' => $this->teamToDelete]); + + Livewire::test(DangerZone::class) + ->assertSee('This team still owns:') + ->assertSee('2 projects') + ->assertSeeHtml('href="'.route('project.index').'"'); +}); + test('a team with a running application cannot be deleted', function () { $server = Server::factory()->create(['team_id' => $this->teamToDelete->id]); $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); From 53117611e476e24f8779b5642518bcff8bcfe187 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:28:15 +0200 Subject: [PATCH 03/53] fix(teams): allow deletion with system-wide Git sources (#11500) --- app/Models/Team.php | 4 +-- .../views/livewire/team/danger-zone.blade.php | 3 +- tests/Feature/Team/TeamDeletionTest.php | 33 +++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/app/Models/Team.php b/app/Models/Team.php index d5957af5ab..4cf6391231 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -283,8 +283,8 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen return array_filter([ 'projects' => $this->projects()->count(), 'servers' => $this->servers()->count(), - 'sources' => GithubApp::query()->where('team_id', $this->id)->count() - + GitlabApp::query()->where('team_id', $this->id)->count(), + 'sources' => GithubApp::query()->where('team_id', $this->id)->where('is_system_wide', false)->count() + + GitlabApp::query()->where('team_id', $this->id)->where('is_system_wide', false)->count(), ]); } diff --git a/resources/views/livewire/team/danger-zone.blade.php b/resources/views/livewire/team/danger-zone.blade.php index 3d8d78a911..b31ef2c5ab 100644 --- a/resources/views/livewire/team/danger-zone.blade.php +++ b/resources/views/livewire/team/danger-zone.blade.php @@ -85,7 +85,8 @@ confirmationText="{{ currentTeam()->name }}" confirmationLabel="Enter the team name to confirm permanent deletion" shortConfirmationLabel="Team name" :confirmWithPassword="false" - step2ButtonText="Permanently Delete" /> + step2ButtonText="Permanently Delete" canGate="delete" + :canResource="$team" /> @else Delete team diff --git a/tests/Feature/Team/TeamDeletionTest.php b/tests/Feature/Team/TeamDeletionTest.php index 8d448d2ada..e449a55639 100644 --- a/tests/Feature/Team/TeamDeletionTest.php +++ b/tests/Feature/Team/TeamDeletionTest.php @@ -73,6 +73,39 @@ test('unused private keys do not block team deletion', function () { ->and(PrivateKey::find($privateKey->id))->toBeNull(); }); +test('system-wide git sources do not block team deletion', function () { + Team::forceCreate([ + 'id' => 0, + 'name' => 'Root Team', + 'personal_team' => false, + ]); + + $githubApp = GithubApp::forceCreate([ + 'name' => 'System-wide GitHub source', + 'team_id' => $this->teamToDelete->id, + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'is_public' => false, + 'is_system_wide' => true, + ]); + $gitlabApp = GitlabApp::forceCreate([ + 'name' => 'System-wide GitLab source', + 'team_id' => $this->teamToDelete->id, + 'api_url' => 'https://gitlab.com/api/v4', + 'html_url' => 'https://gitlab.com', + 'is_public' => false, + 'is_system_wide' => true, + ]); + + expect($this->teamToDelete->isEmpty())->toBeTrue(); + + app(DeleteTeam::class)->handle($this->teamToDelete, $this->owner); + + expect(Team::find($this->teamToDelete->id))->toBeNull() + ->and($githubApp->refresh()->team_id)->toBe(0) + ->and($gitlabApp->refresh()->team_id)->toBe(0); +}); + test('the danger zone names and links blocking resource types', function () { Project::factory()->count(2)->create(['team_id' => $this->teamToDelete->id]); From cfca1558906f3fb52ea0da4a09853bab3193231e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:50:33 +0200 Subject: [PATCH 04/53] fix(deployments): honor stop grace period in compose services (#11498) --- app/Jobs/ApplicationDeploymentJob.php | 4 + ...plicationDeploymentStopGracePeriodTest.php | 85 +++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 tests/Unit/ApplicationDeploymentStopGracePeriodTest.php diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 451d4377c2..1b0ccb2184 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -3301,6 +3301,10 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); // Always use .env file $docker_compose['services'][$this->container_name]['env_file'] = ['.env']; + if ($this->application->settings->stop_grace_period !== null) { + $docker_compose['services'][$this->container_name]['stop_grace_period'] = $this->application->settings->stopGracePeriodSeconds().'s'; + } + // Only add Coolify healthcheck if no custom HEALTHCHECK found in Dockerfile // If custom_healthcheck_found is true, the Dockerfile's HEALTHCHECK will be used // If healthcheck is disabled, no healthcheck will be added diff --git a/tests/Unit/ApplicationDeploymentStopGracePeriodTest.php b/tests/Unit/ApplicationDeploymentStopGracePeriodTest.php new file mode 100644 index 0000000000..4aa45c0431 --- /dev/null +++ b/tests/Unit/ApplicationDeploymentStopGracePeriodTest.php @@ -0,0 +1,85 @@ + 'Stop Grace Period Team', + 'personal_team' => false, + 'show_boarding' => false, + ]); + $project = Project::create([ + 'name' => 'Stop Grace Period Project', + 'team_id' => $team->id, + ]); + $environment = Environment::where('project_id', $project->id)->firstOrFail(); + $server = Server::factory()->create(['team_id' => $team->id]); + $destination = $server->standaloneDockers()->firstOrFail(); + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'nixpacks', + ]); + $application->settings()->update(['stop_grace_period' => $stopGracePeriod]); + + $queue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial(); + $queue->status = 'queued'; + $queue->shouldReceive('refresh')->once()->andReturnSelf(); + + $job = new TestableStopGracePeriodDeploymentJob; + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + + foreach ([ + 'application' => $application->fresh(), + 'application_deployment_queue' => $queue, + 'destination' => $destination, + 'server' => $server, + 'mainServer' => $server, + 'pull_request_id' => 0, + 'container_name' => 'stop-grace-period-app', + 'production_image_name' => 'example/app:latest', + 'deployment_uuid' => 'deployment-uuid', + 'workdir' => '/artifacts/stop-grace-period-app', + 'configuration_dir' => '/data/coolify/applications/test', + 'saved_outputs' => new Collection, + ] as $property => $value) { + $reflection->getProperty($property)->setValue($job, $value); + } + + $reflection->getMethod('generate_compose_file')->invoke($job); + $compose = Yaml::parse($reflection->getProperty('docker_compose')->getValue($job)); + + return $compose['services']['stop-grace-period-app']; +} + +it('adds an explicitly configured stop grace period to generated compose services', function () { + expect(generateComposeServiceWithStopGracePeriod(700)) + ->toHaveKey('stop_grace_period', '700s'); +}); + +it('omits the stop grace period from generated compose services when it is not configured', function () { + expect(generateComposeServiceWithStopGracePeriod(null)) + ->not->toHaveKey('stop_grace_period'); +}); From abeb839a69d4bd550071683b560c3be4add6f1bd Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:12:12 +0200 Subject: [PATCH 05/53] fix(deployments): restore commit links in deployment logs (#11495) --- app/Models/Application.php | 24 ++++++------- .../application/deployment/index.blade.php | 34 ++++++++++-------- tests/Feature/DeploymentLogsLayoutTest.php | 35 +++++++++++++++++++ tests/Unit/ApplicationGitCommitLinkTest.php | 28 +++++++++++++++ 4 files changed, 92 insertions(+), 29 deletions(-) create mode 100644 tests/Unit/ApplicationGitCommitLinkTest.php diff --git a/app/Models/Application.php b/app/Models/Application.php index 0868bdf9cd..af95cd4db6 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -739,24 +739,20 @@ class Application extends BaseModel return "{$this->source->html_url}/{$this->git_repository}/commit/{$link}"; } - if (str($this->git_repository)->contains('bitbucket')) { - $git_repository = str_replace('.git', '', $this->git_repository); - $url = Url::fromString($git_repository); - $url = $url->withUserInfo(''); - $url = $url->withPath($url->getPath().'/commits/'.$link); - return $url->__toString(); - } + $git_repository = $this->git_repository; if (strpos($this->git_repository, 'git@') === 0) { - $git_repository = str_replace(['git@', ':', '.git'], ['', '/', ''], $this->git_repository); - if (data_get($this, 'source.html_url')) { - return "{$this->source->html_url}/{$git_repository}/commit/{$link}"; - } - - return "{$git_repository}/commit/{$link}"; + $git_repository = preg_replace('/^git@([^:]+):/', 'https://$1/', $git_repository); + } elseif (str($this->git_repository)->startsWith('ssh://')) { + $git_repository = 'https://'.parse_url($git_repository, PHP_URL_HOST).parse_url($git_repository, PHP_URL_PATH); } - return $this->git_repository; + $url = Url::fromString(Str::replaceEnd('.git', '', $git_repository)); + $url = $url->withUserInfo(''); + $commitPath = str($git_repository)->contains('bitbucket') ? 'commits' : 'commit'; + $url = $url->withPath(Str::finish($url->getPath(), '/').$commitPath.'/'.$link); + + return $url->__toString(); } public function dockerfileLocation(): Attribute diff --git a/resources/views/livewire/project/application/deployment/index.blade.php b/resources/views/livewire/project/application/deployment/index.blade.php index 88301d71c6..b2c2be9081 100644 --- a/resources/views/livewire/project/application/deployment/index.blade.php +++ b/resources/views/livewire/project/application/deployment/index.blade.php @@ -213,39 +213,43 @@ $commitMessage = $deployment->commitMessage() ? Str::before($deployment->commitMessage(), "\n") : null; + $deploymentUrl = $current_url . '/' . data_get($deployment, 'deployment_uuid'); + $commitUrl = data_get($deployment, 'commit') + ? $application->gitCommitLink(data_get($deployment, 'commit')) + : null; @endphp - $selectedDeploymentUuid === data_get($deployment, 'deployment_uuid'), ])> - - {{ $sourceLabel }} + + {{ $sourceLabel }} @if (data_get($deployment, 'commit')) - + {{ substr(data_get($deployment, 'commit'), 0, 7) }} - + @if ($commitMessage) - {{ $commitMessage }} + {{ $commitMessage }} @endif @else - @endif - + {{ \Carbon\Carbon::parse(data_get($deployment, 'created_at'))->diffForHumans() }} - - {{ $duration }} - + + {{ $duration }} + {{ data_get($deployment, 'server_name') ?: data_get($application, 'destination.server.name', '-') }} - - + + @endforeach diff --git a/tests/Feature/DeploymentLogsLayoutTest.php b/tests/Feature/DeploymentLogsLayoutTest.php index dc0b921003..be825c663b 100644 --- a/tests/Feature/DeploymentLogsLayoutTest.php +++ b/tests/Feature/DeploymentLogsLayoutTest.php @@ -4,6 +4,7 @@ use App\Enums\ApplicationDeploymentStatus; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; use App\Models\Environment; +use App\Models\GithubApp; use App\Models\InstanceSettings; use App\Models\Project; use App\Models\Server; @@ -214,6 +215,40 @@ it('keeps deployment history fields and the log status badge accessible on mobil ->toContain(".logs-viewer-primary .logs-viewer-actions {\n width: auto;\n flex: 1 1 auto;"); }); +it('links deployment commit hashes to the source commit page', function () { + $githubApp = GithubApp::query()->create([ + 'team_id' => $this->team->id, + 'name' => 'GitHub', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + ]); + $this->application->update([ + 'source_id' => $githubApp->id, + 'source_type' => $githubApp->getMorphClass(), + 'git_repository' => 'coollabsio/coolify', + 'git_branch' => 'main', + ]); + ApplicationDeploymentQueue::query()->create([ + 'application_id' => $this->application->id, + 'deployment_uuid' => 'deploy-commit-link-test', + 'server_id' => $this->server->id, + 'status' => ApplicationDeploymentStatus::FINISHED->value, + 'commit' => '1234567890abcdef1234567890abcdef12345678', + ]); + + $response = $this->get(route('project.application.deployment.index', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'application_uuid' => $this->application->uuid, + ])); + + $response->assertSuccessful(); + $response->assertSee( + 'href="https://github.com/coollabsio/coolify/commit/1234567890abcdef1234567890abcdef12345678" target="_blank" rel="noopener noreferrer"', + false, + ); +}); + it('places cancel deployment controls inside the deployment logs toolbar', function () { $deployment = ApplicationDeploymentQueue::create([ 'application_id' => $this->application->id, diff --git a/tests/Unit/ApplicationGitCommitLinkTest.php b/tests/Unit/ApplicationGitCommitLinkTest.php new file mode 100644 index 0000000000..e1a15e7149 --- /dev/null +++ b/tests/Unit/ApplicationGitCommitLinkTest.php @@ -0,0 +1,28 @@ +setRelation('source', null); + $application->git_repository = $repository; + + expect($application->gitCommitLink('1234567890abcdef'))->toBe($expected); +})->with([ + 'HTTPS remote' => [ + 'https://github.com/coollabsio/coolify.git', + 'https://github.com/coollabsio/coolify/commit/1234567890abcdef', + ], + 'SSH remote' => [ + 'git@github.com:coollabsio/coolify.git', + 'https://github.com/coollabsio/coolify/commit/1234567890abcdef', + ], + 'SSH URL' => [ + 'ssh://git@gitlab.com/coollabsio/coolify.git', + 'https://gitlab.com/coollabsio/coolify/commit/1234567890abcdef', + ], + 'Bitbucket HTTPS remote' => [ + 'https://bitbucket.org/coollabsio/coolify.git', + 'https://bitbucket.org/coollabsio/coolify/commits/1234567890abcdef', + ], +]); From 3d0caa236f9dcc0207e4f000d6735c84e1530826 Mon Sep 17 00:00:00 2001 From: Omid Sayfun Date: Tue, 25 Aug 2026 12:27:33 +0200 Subject: [PATCH 06/53] fix(deployments): apply custom docker options when custom container name is set (#11479) Co-authored-by: Claude Opus 5 Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> --- app/Jobs/ApplicationDeploymentJob.php | 29 +++--- ...ationDeploymentCustomDockerOptionsTest.php | 97 +++++++++++++++++++ 2 files changed, 109 insertions(+), 17 deletions(-) create mode 100644 tests/Unit/ApplicationDeploymentCustomDockerOptionsTest.php diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 1b0ccb2184..327fea25e7 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -3427,24 +3427,19 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); if ($this->pull_request_id === 0) { $custom_compose = convertDockerRunToCompose($this->application->custom_docker_run_options); if ((bool) $this->application->settings->is_consistent_container_name_enabled) { - if (! $this->application->settings->custom_internal_name) { - $docker_compose['services'][$this->application->uuid] = $docker_compose['services'][$this->container_name]; - if (count($custom_compose) > 0) { - $ipv4 = data_get($custom_compose, 'ip.0'); - $ipv6 = data_get($custom_compose, 'ip6.0'); - data_forget($custom_compose, 'ip'); - data_forget($custom_compose, 'ip6'); - if ($ipv4 || $ipv6) { - data_forget($docker_compose['services'][$this->application->uuid], 'networks'); - } - if ($ipv4) { - $docker_compose['services'][$this->application->uuid]['networks'][$this->destination->network]['ipv4_address'] = $ipv4; - } - if ($ipv6) { - $docker_compose['services'][$this->application->uuid]['networks'][$this->destination->network]['ipv6_address'] = $ipv6; - } - $docker_compose['services'][$this->application->uuid] = array_merge_recursive($docker_compose['services'][$this->application->uuid], $custom_compose); + $docker_compose['services'][$this->application->uuid] = $docker_compose['services'][$this->container_name]; + if (count($custom_compose) > 0) { + $ipv4 = data_get($custom_compose, 'ip.0'); + $ipv6 = data_get($custom_compose, 'ip6.0'); + data_forget($custom_compose, 'ip'); + data_forget($custom_compose, 'ip6'); + if ($ipv4) { + $docker_compose['services'][$this->application->uuid]['networks'][$this->destination->network]['ipv4_address'] = $ipv4; } + if ($ipv6) { + $docker_compose['services'][$this->application->uuid]['networks'][$this->destination->network]['ipv6_address'] = $ipv6; + } + $docker_compose['services'][$this->application->uuid] = array_merge_recursive($docker_compose['services'][$this->application->uuid], $custom_compose); } } else { if (count($custom_compose) > 0) { diff --git a/tests/Unit/ApplicationDeploymentCustomDockerOptionsTest.php b/tests/Unit/ApplicationDeploymentCustomDockerOptionsTest.php new file mode 100644 index 0000000000..9c671156d1 --- /dev/null +++ b/tests/Unit/ApplicationDeploymentCustomDockerOptionsTest.php @@ -0,0 +1,97 @@ + 'Custom Docker Options Team', + 'personal_team' => false, + 'show_boarding' => false, + ]); + $project = Project::create([ + 'name' => 'Custom Docker Options Project', + 'team_id' => $team->id, + ]); + $environment = Environment::where('project_id', $project->id)->firstOrFail(); + $server = Server::factory()->create(['team_id' => $team->id]); + $destination = $server->standaloneDockers()->firstOrFail(); + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'dockerimage', + 'custom_docker_run_options' => $customDockerOptions, + 'custom_network_aliases' => json_encode(['custom-alias'], JSON_THROW_ON_ERROR), + ]); + $application->settings()->update([ + 'is_consistent_container_name_enabled' => true, + 'custom_internal_name' => 'custom-internal-name', + ]); + + $queue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial(); + $queue->status = 'queued'; + $queue->shouldReceive('refresh')->once()->andReturnSelf(); + + $application = $application->fresh(); + $job = new TestableCustomDockerOptionsDeploymentJob; + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + + foreach ([ + 'application' => $application, + 'application_deployment_queue' => $queue, + 'destination' => $destination, + 'server' => $server, + 'mainServer' => $server, + 'pull_request_id' => 0, + 'container_name' => $application->uuid, + 'production_image_name' => 'example/app:latest', + 'deployment_uuid' => 'deployment-uuid', + 'workdir' => '/artifacts/custom-docker-options-app', + 'configuration_dir' => '/data/coolify/applications/test', + 'saved_outputs' => new Collection, + ] as $property => $value) { + $reflection->getProperty($property)->setValue($job, $value); + } + + $reflection->getMethod('generate_compose_file')->invoke($job); + $compose = Yaml::parse($reflection->getProperty('docker_compose')->getValue($job)); + + return $compose['services'][$application->uuid]; +} + +it('applies an entrypoint when consistent naming and a custom internal name are configured', function () { + expect(generateComposeServiceWithCustomDockerOptions('--entrypoint "/bin/echo hello world"')) + ->toHaveKey('entrypoint', '/bin/echo hello world'); +}); + +it('preserves custom network aliases when a static IP is configured', function () { + $service = generateComposeServiceWithCustomDockerOptions('--ip 10.0.0.25 --ip6 2001:db8::25'); + $network = $service['networks'][array_key_first($service['networks'])]; + + expect($network) + ->toHaveKey('ipv4_address', '10.0.0.25') + ->toHaveKey('ipv6_address', '2001:db8::25') + ->toHaveKey('aliases') + ->and($network['aliases'])->toContain('custom-alias'); +}); From 1ad85adbcf74c55ef1a87f8d22902ac608ac19f5 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:16:23 +0200 Subject: [PATCH 07/53] fix(resources): make cleanup scalable and deletion atomic --- .../Commands/CleanupStuckedResources.php | 116 ++++++++---------- app/Jobs/DeleteResourceJob.php | 48 +++++--- tests/Feature/CleanupStuckedResourcesTest.php | 44 +++++++ .../DeleteResourceJobAtomicityTest.php | 47 +++++++ 4 files changed, 176 insertions(+), 79 deletions(-) create mode 100644 tests/Feature/CleanupStuckedResourcesTest.php diff --git a/app/Console/Commands/CleanupStuckedResources.php b/app/Console/Commands/CleanupStuckedResources.php index 165a3ae219..0874970fb6 100644 --- a/app/Console/Commands/CleanupStuckedResources.php +++ b/app/Console/Commands/CleanupStuckedResources.php @@ -13,7 +13,6 @@ use App\Models\Server; use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; -use App\Models\SslCertificate; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; @@ -39,13 +38,14 @@ class CleanupStuckedResources extends Command private function cleanup_stucked_resources() { try { - $teams = Team::all()->filter(function ($team) { - return $team->members()->count() === 0 && $team->servers()->count() === 0; - }); + $teams = Team::query() + ->whereDoesntHave('members') + ->whereDoesntHave('servers') + ->lazyById(); foreach ($teams as $team) { $team->delete(); } - $servers = Server::all()->filter(function ($server) { + $servers = Server::query()->with('team.subscription')->lazyById()->filter(function ($server) { return $server->isFunctional(); }); if (isCloud()) { @@ -60,7 +60,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stucked resources: {$e->getMessage()}\n"; } try { - $servers = Server::onlyTrashed()->get(); + $servers = Server::onlyTrashed()->lazyById(); foreach ($servers as $server) { echo "Force deleting stuck server: {$server->name}\n"; $server->forceDelete(); @@ -69,7 +69,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck servers: {$e->getMessage()}\n"; } try { - $applicationsDeploymentQueue = ApplicationDeploymentQueue::get(); + $applicationsDeploymentQueue = ApplicationDeploymentQueue::query()->lazyById(); foreach ($applicationsDeploymentQueue as $applicationDeploymentQueue) { if (is_null($applicationDeploymentQueue->application)) { echo "Deleting stuck application deployment queue: {$applicationDeploymentQueue->id}\n"; @@ -80,7 +80,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck application deployment queue: {$e->getMessage()}\n"; } try { - $applications = Application::withTrashed()->whereNotNull('deleted_at')->get(); + $applications = Application::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($applications as $application) { echo "Deleting stuck application: {$application->name}\n"; DeleteResourceJob::dispatch($application); @@ -89,18 +89,18 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck application: {$e->getMessage()}\n"; } try { - $applicationsPreviews = ApplicationPreview::get(); + $applicationsPreviews = ApplicationPreview::query() + ->whereDoesntHave('application') + ->lazyById(); foreach ($applicationsPreviews as $applicationPreview) { - if (! data_get($applicationPreview, 'application')) { - echo "Deleting stuck application preview: {$applicationPreview->uuid}\n"; - DeleteResourceJob::dispatch($applicationPreview); - } + echo "Deleting stuck application preview: {$applicationPreview->uuid}\n"; + DeleteResourceJob::dispatch($applicationPreview); } } catch (\Throwable $e) { echo "Error in cleaning stuck application: {$e->getMessage()}\n"; } try { - $applicationsPreviews = ApplicationPreview::withTrashed()->whereNotNull('deleted_at')->get(); + $applicationsPreviews = ApplicationPreview::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($applicationsPreviews as $applicationPreview) { echo "Deleting stuck application preview: {$applicationPreview->fqdn}\n"; DeleteResourceJob::dispatch($applicationPreview); @@ -109,7 +109,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck application: {$e->getMessage()}\n"; } try { - $postgresqls = StandalonePostgresql::withTrashed()->whereNotNull('deleted_at')->get(); + $postgresqls = StandalonePostgresql::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($postgresqls as $postgresql) { echo "Deleting stuck postgresql: {$postgresql->name}\n"; DeleteResourceJob::dispatch($postgresql); @@ -118,7 +118,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck postgresql: {$e->getMessage()}\n"; } try { - $rediss = StandaloneRedis::withTrashed()->whereNotNull('deleted_at')->get(); + $rediss = StandaloneRedis::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($rediss as $redis) { echo "Deleting stuck redis: {$redis->name}\n"; DeleteResourceJob::dispatch($redis); @@ -127,7 +127,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck redis: {$e->getMessage()}\n"; } try { - $keydbs = StandaloneKeydb::withTrashed()->whereNotNull('deleted_at')->get(); + $keydbs = StandaloneKeydb::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($keydbs as $keydb) { echo "Deleting stuck keydb: {$keydb->name}\n"; DeleteResourceJob::dispatch($keydb); @@ -136,7 +136,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck keydb: {$e->getMessage()}\n"; } try { - $dragonflies = StandaloneDragonfly::withTrashed()->whereNotNull('deleted_at')->get(); + $dragonflies = StandaloneDragonfly::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($dragonflies as $dragonfly) { echo "Deleting stuck dragonfly: {$dragonfly->name}\n"; DeleteResourceJob::dispatch($dragonfly); @@ -145,7 +145,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck dragonfly: {$e->getMessage()}\n"; } try { - $clickhouses = StandaloneClickhouse::withTrashed()->whereNotNull('deleted_at')->get(); + $clickhouses = StandaloneClickhouse::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($clickhouses as $clickhouse) { echo "Deleting stuck clickhouse: {$clickhouse->name}\n"; DeleteResourceJob::dispatch($clickhouse); @@ -154,7 +154,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck clickhouse: {$e->getMessage()}\n"; } try { - $mongodbs = StandaloneMongodb::withTrashed()->whereNotNull('deleted_at')->get(); + $mongodbs = StandaloneMongodb::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($mongodbs as $mongodb) { echo "Deleting stuck mongodb: {$mongodb->name}\n"; DeleteResourceJob::dispatch($mongodb); @@ -163,7 +163,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck mongodb: {$e->getMessage()}\n"; } try { - $mysqls = StandaloneMysql::withTrashed()->whereNotNull('deleted_at')->get(); + $mysqls = StandaloneMysql::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($mysqls as $mysql) { echo "Deleting stuck mysql: {$mysql->name}\n"; DeleteResourceJob::dispatch($mysql); @@ -172,7 +172,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck mysql: {$e->getMessage()}\n"; } try { - $mariadbs = StandaloneMariadb::withTrashed()->whereNotNull('deleted_at')->get(); + $mariadbs = StandaloneMariadb::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($mariadbs as $mariadb) { echo "Deleting stuck mariadb: {$mariadb->name}\n"; DeleteResourceJob::dispatch($mariadb); @@ -181,7 +181,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck mariadb: {$e->getMessage()}\n"; } try { - $services = Service::withTrashed()->whereNotNull('deleted_at')->get(); + $services = Service::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($services as $service) { echo "Deleting stuck service: {$service->name}\n"; DeleteResourceJob::dispatch($service); @@ -190,7 +190,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck service: {$e->getMessage()}\n"; } try { - $serviceApps = ServiceApplication::withTrashed()->whereNotNull('deleted_at')->get(); + $serviceApps = ServiceApplication::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($serviceApps as $serviceApp) { echo "Deleting stuck serviceapp: {$serviceApp->name}\n"; $serviceApp->forceDelete(); @@ -199,7 +199,7 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck serviceapp: {$e->getMessage()}\n"; } try { - $serviceDbs = ServiceDatabase::withTrashed()->whereNotNull('deleted_at')->get(); + $serviceDbs = ServiceDatabase::withTrashed()->whereNotNull('deleted_at')->lazyById(); foreach ($serviceDbs as $serviceDb) { echo "Deleting stuck serviceapp: {$serviceDb->name}\n"; $serviceDb->forceDelete(); @@ -208,19 +208,27 @@ class CleanupStuckedResources extends Command echo "Error in cleaning stuck serviceapp: {$e->getMessage()}\n"; } try { - $scheduled_tasks = ScheduledTask::all(); + $scheduled_tasks = ScheduledTask::query() + ->where(function ($query): void { + $query->where(function ($query): void { + $query->whereNull('application_id')->whereNull('service_id'); + })->orWhere(function ($query): void { + $query->whereNotNull('application_id')->whereDoesntHave('application'); + })->orWhere(function ($query): void { + $query->whereNotNull('service_id')->whereDoesntHave('service'); + }); + }) + ->lazyById(); foreach ($scheduled_tasks as $scheduled_task) { - if (! $scheduled_task->service && ! $scheduled_task->application) { - echo "Deleting stuck scheduledtask: {$scheduled_task->name}\n"; - $scheduled_task->delete(); - } + echo "Deleting stuck scheduledtask: {$scheduled_task->name}\n"; + $scheduled_task->delete(); } } catch (\Throwable $e) { echo "Error in cleaning stuck scheduledtasks: {$e->getMessage()}\n"; } try { - $scheduled_backups = ScheduledDatabaseBackup::all(); + $scheduled_backups = ScheduledDatabaseBackup::query()->lazyById(); foreach ($scheduled_backups as $scheduled_backup) { try { $server = $scheduled_backup->server(); @@ -238,7 +246,7 @@ class CleanupStuckedResources extends Command // Cleanup any resources that are not attached to any environment or destination or server try { - $applications = Application::all(); + $applications = Application::query()->lazyById(); foreach ($applications as $application) { if (! data_get($application, 'environment')) { echo 'Application without environment: '.$application->name.'\n'; @@ -263,7 +271,7 @@ class CleanupStuckedResources extends Command echo "Error in application: {$e->getMessage()}\n"; } try { - $postgresqls = StandalonePostgresql::all()->where('id', '!=', 0); + $postgresqls = StandalonePostgresql::query()->where('id', '!=', 0)->lazyById(); foreach ($postgresqls as $postgresql) { if (! data_get($postgresql, 'environment')) { echo 'Postgresql without environment: '.$postgresql->name.'\n'; @@ -288,7 +296,7 @@ class CleanupStuckedResources extends Command echo "Error in postgresql: {$e->getMessage()}\n"; } try { - $redis = StandaloneRedis::all(); + $redis = StandaloneRedis::query()->lazyById(); foreach ($redis as $redis) { if (! data_get($redis, 'environment')) { echo 'Redis without environment: '.$redis->name.'\n'; @@ -314,7 +322,7 @@ class CleanupStuckedResources extends Command } try { - $mongodbs = StandaloneMongodb::all(); + $mongodbs = StandaloneMongodb::query()->lazyById(); foreach ($mongodbs as $mongodb) { if (! data_get($mongodb, 'environment')) { echo 'Mongodb without environment: '.$mongodb->name.'\n'; @@ -340,7 +348,7 @@ class CleanupStuckedResources extends Command } try { - $mysqls = StandaloneMysql::all(); + $mysqls = StandaloneMysql::query()->lazyById(); foreach ($mysqls as $mysql) { if (! data_get($mysql, 'environment')) { echo 'Mysql without environment: '.$mysql->name.'\n'; @@ -366,7 +374,7 @@ class CleanupStuckedResources extends Command } try { - $mariadbs = StandaloneMariadb::all(); + $mariadbs = StandaloneMariadb::query()->lazyById(); foreach ($mariadbs as $mariadb) { if (! data_get($mariadb, 'environment')) { echo 'Mariadb without environment: '.$mariadb->name.'\n'; @@ -392,7 +400,7 @@ class CleanupStuckedResources extends Command } try { - $services = Service::all(); + $services = Service::query()->lazyById(); foreach ($services as $service) { if (! data_get($service, 'environment')) { echo 'Service without environment: '.$service->name.'\n'; @@ -417,43 +425,23 @@ class CleanupStuckedResources extends Command echo "Error in service: {$e->getMessage()}\n"; } try { - $serviceApplications = ServiceApplication::all(); + $serviceApplications = ServiceApplication::query()->whereDoesntHave('service')->lazyById(); foreach ($serviceApplications as $service) { - if (! data_get($service, 'service')) { - echo 'ServiceApplication without service: '.$service->name.'\n'; - $service->forceDelete(); - - continue; - } + echo 'ServiceApplication without service: '.$service->name.'\n'; + $service->forceDelete(); } } catch (\Throwable $e) { echo "Error in serviceApplications: {$e->getMessage()}\n"; } try { - $serviceDatabases = ServiceDatabase::all(); + $serviceDatabases = ServiceDatabase::query()->whereDoesntHave('service')->lazyById(); foreach ($serviceDatabases as $service) { - if (! data_get($service, 'service')) { - echo 'ServiceDatabase without service: '.$service->name.'\n'; - $service->forceDelete(); - - continue; - } + echo 'ServiceDatabase without service: '.$service->name.'\n'; + $service->forceDelete(); } } catch (\Throwable $e) { echo "Error in ServiceDatabases: {$e->getMessage()}\n"; } - try { - $orphanedCerts = SslCertificate::whereNotIn('server_id', function ($query) { - $query->select('id')->from('servers'); - })->get(); - - foreach ($orphanedCerts as $cert) { - echo "Deleting orphaned SSL certificate: {$cert->id} (server_id: {$cert->server_id})\n"; - $cert->delete(); - } - } catch (\Throwable $e) { - echo "Error in cleaning orphaned SSL certificates: {$e->getMessage()}\n"; - } } } diff --git a/app/Jobs/DeleteResourceJob.php b/app/Jobs/DeleteResourceJob.php index dff7d88de1..e6f63ec669 100644 --- a/app/Jobs/DeleteResourceJob.php +++ b/app/Jobs/DeleteResourceJob.php @@ -47,9 +47,7 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue public function handle(): void { if ($this->resource instanceof ApplicationPreview) { - DB::transaction(function (): void { - $this->deleteApplicationPreview(); - }); + $this->deleteApplicationPreview(); return; } @@ -99,17 +97,17 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue ]); } - DB::transaction(function (): void { - try { - $this->deleteScheduledVolumeBackups(); - } catch (\Throwable $e) { - Log::warning('Remote backup cleanup failed while deleting resource; continuing with local deletion.', [ - 'resource_id' => $this->resource->id, - 'resource_type' => $this->resource->type(), - 'error' => $e->getMessage(), - ]); - } + try { + $this->deleteScheduledVolumeBackups(); + } catch (\Throwable $e) { + Log::warning('Remote backup cleanup failed while deleting resource; continuing with local deletion.', [ + 'resource_id' => $this->resource->id, + 'resource_type' => $this->resource->type(), + 'error' => $e->getMessage(), + ]); + } + DB::transaction(function (): void { if ($this->resource instanceof Service) { app(DeleteService::class)->deleteLocal($this->resource); @@ -163,10 +161,22 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue } } - private function deleteApplicationPreview() + private function deleteApplicationPreview(): void { $application = $this->resource->application; - $server = $application->destination->server; + + if (! $application) { + $this->deleteApplicationPreviewLocally(); + + return; + } + + $server = $application->destination?->server; + if (! $server) { + $this->deleteApplicationPreviewLocally(); + + return; + } $pull_request_id = $this->resource->pull_request_id; // Ensure the preview is soft deleted (may already be done in Livewire component) @@ -239,6 +249,14 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue $this->resource->forceDelete(); } + private function deleteApplicationPreviewLocally(): void + { + DB::transaction(function (): void { + $this->resource->persistentStorages()->delete(); + ApplicationPreview::withoutEvents(fn () => $this->resource->forceDelete()); + }); + } + private function stopPreviewContainers(array $containers, $server, int $timeout = 30) { if (empty($containers)) { diff --git a/tests/Feature/CleanupStuckedResourcesTest.php b/tests/Feature/CleanupStuckedResourcesTest.php new file mode 100644 index 0000000000..24229cf3d1 --- /dev/null +++ b/tests/Feature/CleanupStuckedResourcesTest.php @@ -0,0 +1,44 @@ + InstanceSettings::firstOrCreate(['id' => 0])); + Queue::fake(); + + $team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = $project->environments()->first() + ?? Environment::factory()->create(['project_id' => $project->id]); + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $validTask = ScheduledTask::factory()->create([ + 'team_id' => $team->id, + 'application_id' => $application->id, + ]); + $orphanedTask = ScheduledTask::factory()->create([ + 'team_id' => $team->id, + 'application_id' => PHP_INT_MAX, + ]); + + $this->artisan('cleanup:stucked-resources')->assertSuccessful(); + + expect($validTask->fresh())->not->toBeNull() + ->and($orphanedTask->fresh())->toBeNull(); +}); diff --git a/tests/Feature/DeleteResourceJobAtomicityTest.php b/tests/Feature/DeleteResourceJobAtomicityTest.php index 11955a82cd..758227004d 100644 --- a/tests/Feature/DeleteResourceJobAtomicityTest.php +++ b/tests/Feature/DeleteResourceJobAtomicityTest.php @@ -2,13 +2,16 @@ use App\Jobs\DeleteResourceJob; use App\Models\Application; +use App\Models\ApplicationPreview; use App\Models\Environment; use App\Models\InstanceSettings; use App\Models\Project; +use App\Models\ScheduledVolumeBackup; use App\Models\Server; use App\Models\StandaloneDocker; use App\Models\Team; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Queue; @@ -62,3 +65,47 @@ it('rolls back local metadata deletion when deleting the resource fails', functi expect($this->storage->fresh())->not->toBeNull() ->and(Application::withTrashed()->find($this->application->id))->not->toBeNull(); }); + +it('deletes scheduled volume backups outside the local deletion transaction', function () { + $backup = $this->storage->scheduledBackups()->create([ + 'team_id' => $this->application->environment->project->team_id, + 'frequency' => 'daily', + 'timeout' => 3600, + ]); + $transactionLevel = DB::transactionLevel(); + $deletingTransactionLevel = null; + + ScheduledVolumeBackup::deleting(function (ScheduledVolumeBackup $deletingBackup) use ($backup, &$deletingTransactionLevel): void { + if ($deletingBackup->is($backup)) { + $deletingTransactionLevel = DB::transactionLevel(); + } + }); + Process::fake(['*' => Process::result(output: '')]); + + (new DeleteResourceJob($this->application))->handle(); + + expect($deletingTransactionLevel)->toBe($transactionLevel); +}); + +it('deletes preview metadata locally when its application destination is missing', function () { + $this->application->restore(); + $this->application->update(['destination_id' => PHP_INT_MAX]); + $preview = ApplicationPreview::create([ + 'uuid' => 'preview-without-destination', + 'application_id' => $this->application->id, + 'pull_request_id' => 45, + 'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/45', + ]); + $previewStorage = $preview->persistentStorages()->create([ + 'name' => 'preview-without-destination-data', + 'mount_path' => '/preview-data', + 'host_path' => null, + ]); + Process::fake(); + + (new DeleteResourceJob($preview))->handle(); + + Process::assertNothingRan(); + expect(ApplicationPreview::withTrashed()->find($preview->id))->toBeNull() + ->and($previewStorage->fresh())->toBeNull(); +}); From 45f9a33985544421e04f7d4c407a8dc9136c3dc4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:25:40 +0200 Subject: [PATCH 08/53] fix(resources): schedule stuck cleanup and delete metadata before jobs Soft-delete API resources before dispatching cleanup jobs and run stuck-resource cleanup daily on one server. --- app/Console/Kernel.php | 4 ++ .../Api/ApplicationsController.php | 2 + .../Controllers/Api/DatabasesController.php | 2 + .../Controllers/Api/ServicesController.php | 2 + app/Jobs/DeleteResourceJob.php | 2 - tests/Feature/Api/LifecycleApisTest.php | 55 +++++++++++++++++++ .../DeleteResourceJobAtomicityTest.php | 2 + tests/Feature/ScheduleOnOneServerTest.php | 12 ++++ 8 files changed, 79 insertions(+), 2 deletions(-) diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index e6dc323838..8d4d017c81 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -46,6 +46,10 @@ class Kernel extends ConsoleKernel ->hourly() ->when(fn () => config('constants.ssh.mux_enabled') && ! config('constants.coolify.is_windows_docker_desktop')); $this->scheduleInstance->command('cleanup:redis --clear-locks')->daily(); + $this->scheduleInstance->command('cleanup:stucked-resources') + ->daily() + ->onOneServer() + ->withoutOverlapping(60); $this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer(); $this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer(); diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 285cf8e2f1..a0e1714ad6 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -2560,6 +2560,8 @@ class ApplicationsController extends Controller $this->authorize('delete', $application); + $application->delete(); + DeleteResourceJob::dispatch( resource: $application, deleteVolumes: $request->boolean('delete_volumes', true), diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 7b62b4980a..aeb69ac8b4 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -2586,6 +2586,8 @@ class DatabasesController extends Controller $this->authorize('delete', $database); + $database->delete(); + DeleteResourceJob::dispatch( resource: $database, deleteVolumes: $request->boolean('delete_volumes', true), diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index d7d4953f9c..9bfcfd8539 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -1020,6 +1020,8 @@ class ServicesController extends Controller $this->authorize('delete', $service); + $service->delete(); + DeleteResourceJob::dispatch( resource: $service, deleteVolumes: $request->boolean('delete_volumes', true), diff --git a/app/Jobs/DeleteResourceJob.php b/app/Jobs/DeleteResourceJob.php index e6f63ec669..d07f346e56 100644 --- a/app/Jobs/DeleteResourceJob.php +++ b/app/Jobs/DeleteResourceJob.php @@ -26,7 +26,6 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -128,7 +127,6 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue $this->resource->forceDelete(); }); - Artisan::queue('cleanup:stucked-resources'); } private function isDatabase(): bool diff --git a/tests/Feature/Api/LifecycleApisTest.php b/tests/Feature/Api/LifecycleApisTest.php index 913eac59a5..4973453e93 100644 --- a/tests/Feature/Api/LifecycleApisTest.php +++ b/tests/Feature/Api/LifecycleApisTest.php @@ -1,5 +1,6 @@ withHeaders($this->headers) + ->deleteJson("/api/v1/applications/{$this->application->uuid}") + ->assertOk(); + + expect(Application::find($this->application->id))->toBeNull() + ->and(Application::withTrashed()->find($this->application->id)?->trashed())->toBeTrue(); + Queue::assertPushed(DeleteResourceJob::class); + }); + + test('soft deletes a service before queuing cleanup', function () { + Queue::fake(); + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'server_id' => $this->server->id, + ]); + + $this->withHeaders($this->headers) + ->deleteJson("/api/v1/services/{$service->uuid}") + ->assertOk(); + + expect(Service::find($service->id))->toBeNull() + ->and(Service::withTrashed()->find($service->id)?->trashed())->toBeTrue(); + Queue::assertPushed(DeleteResourceJob::class); + }); + + test('soft deletes a database before queuing cleanup', function () { + Queue::fake(); + $database = StandalonePostgresql::create([ + 'name' => 'database-to-delete', + 'image' => 'postgres:17-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $this->withHeaders($this->headers) + ->deleteJson("/api/v1/databases/{$database->uuid}") + ->assertOk(); + + expect(StandalonePostgresql::find($database->id))->toBeNull() + ->and(StandalonePostgresql::withTrashed()->find($database->id)?->trashed())->toBeTrue(); + Queue::assertPushed(DeleteResourceJob::class); + }); +}); + describe('POST /api/v1/applications/{uuid}/scheduled-tasks/{task_uuid}/execute', function () { test('dispatches ScheduledTaskJob for an application task', function () { Queue::fake(); diff --git a/tests/Feature/DeleteResourceJobAtomicityTest.php b/tests/Feature/DeleteResourceJobAtomicityTest.php index 758227004d..801db44d14 100644 --- a/tests/Feature/DeleteResourceJobAtomicityTest.php +++ b/tests/Feature/DeleteResourceJobAtomicityTest.php @@ -10,6 +10,7 @@ use App\Models\ScheduledVolumeBackup; use App\Models\Server; use App\Models\StandaloneDocker; use App\Models\Team; +use Illuminate\Foundation\Console\QueuedCommand; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Process; @@ -48,6 +49,7 @@ it('deletes the Coolify resource when remote cleanup fails', function () { (new DeleteResourceJob($this->application))->handle(); expect(Application::withTrashed()->find($this->application->id))->toBeNull(); + Queue::assertNotPushed(QueuedCommand::class); }); it('rolls back local metadata deletion when deleting the resource fails', function () { diff --git a/tests/Feature/ScheduleOnOneServerTest.php b/tests/Feature/ScheduleOnOneServerTest.php index 49d5926892..ef2d80e648 100644 --- a/tests/Feature/ScheduleOnOneServerTest.php +++ b/tests/Feature/ScheduleOnOneServerTest.php @@ -58,3 +58,15 @@ it('does not schedule Stripe subscription reconciliation automatically', functio expect($event)->toBeNull(); }); + +it('schedules stuck resource cleanup once per day on one server', function () { + $schedule = app(Schedule::class); + + $event = collect($schedule->events())->first( + fn ($event) => str_contains($event->command, 'cleanup:stucked-resources') + ); + + expect($event)->not->toBeNull() + ->and($event->expression)->toBe('0 0 * * *') + ->and($event->onOneServer)->toBeTrue(); +}); From 63cbdd5c520cd2dbcd2743881bde251b5a52295d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:51:59 +0200 Subject: [PATCH 09/53] fix(security): preserve private key editor modal identity (#11497) --- app/Livewire/Security/PrivateKey/Index.php | 25 +++++ app/Livewire/Security/PrivateKey/Show.php | 18 ++-- app/View/Components/Forms/Input.php | 2 + .../views/components/forms/input.blade.php | 6 +- .../environment-variable/show.blade.php | 6 +- .../security/private-key/index.blade.php | 45 +++++++-- .../security/private-key/show.blade.php | 11 ++- ...ronmentVariableMultilineToggleViewTest.php | 2 + .../Security/PrivateKeyDropdownTest.php | 4 +- .../SecurityResourceModalEditorsTest.php | 91 ++++++++++++++++++- 10 files changed, 179 insertions(+), 31 deletions(-) diff --git a/app/Livewire/Security/PrivateKey/Index.php b/app/Livewire/Security/PrivateKey/Index.php index 8b170e6ae0..9a7ff4e979 100644 --- a/app/Livewire/Security/PrivateKey/Index.php +++ b/app/Livewire/Security/PrivateKey/Index.php @@ -10,13 +10,38 @@ class Index extends Component { use AuthorizesRequests; + public ?string $selectedPrivateKeyUuid = null; + public function getListeners(): array { return [ 'securityResourceChanged' => '$refresh', + 'privateKeyCreated' => 'refreshResources', + 'privateKeyDeleted' => 'refreshResources', + 'privateKeyUpdated' => 'refreshResources', + 'modalClosed' => 'closeEditor', ]; } + public function openEditor(string $privateKeyUuid): void + { + $privateKey = PrivateKey::ownedByCurrentTeam()->whereUuid($privateKeyUuid)->firstOrFail(); + $this->authorize('view', $privateKey); + + $this->selectedPrivateKeyUuid = $privateKey->uuid; + } + + public function closeEditor(): void + { + $this->selectedPrivateKeyUuid = null; + } + + public function refreshResources(): void + { + $this->closeEditor(); + $this->dispatch('close-modal'); + } + public function generatePrivateKey(string $type) { try { diff --git a/app/Livewire/Security/PrivateKey/Show.php b/app/Livewire/Security/PrivateKey/Show.php index 7fa2300031..664119badc 100644 --- a/app/Livewire/Security/PrivateKey/Show.php +++ b/app/Livewire/Security/PrivateKey/Show.php @@ -92,6 +92,7 @@ class Show extends Component $this->syncData(false); $this->isInUse = $this->private_key->isInUse(); + $this->public_key = $this->private_key->getPublicKey(); } catch (AuthorizationException $e) { abort(403, 'You do not have permission to view this private key.'); } catch (\Throwable) { @@ -99,14 +100,6 @@ class Show extends Component } } - public function loadPublicKey() - { - $this->public_key = $this->private_key->getPublicKey(); - if ($this->public_key === 'Error loading private key') { - $this->dispatch('error', 'Failed to load public key. The private key may be invalid.'); - } - } - public function delete() { try { @@ -123,8 +116,7 @@ class Show extends Component currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get(); if ($this->modalMode) { - $this->dispatch('securityResourceChanged'); - $this->dispatch('close-modal'); + $this->dispatch('privateKeyDeleted'); return null; } @@ -150,10 +142,12 @@ class Show extends Component ]); refresh_server_connection($this->private_key); $this->dispatch('success', 'Private key updated.'); - $this->dispatch('securityResourceChanged'); if ($this->modalMode) { - $this->dispatch('close-modal'); + $this->dispatch('privateKeyUpdated'); + + return null; } + $this->dispatch('securityResourceChanged'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/View/Components/Forms/Input.php b/app/View/Components/Forms/Input.php index 7831c9f024..a1b01e2808 100644 --- a/app/View/Components/Forms/Input.php +++ b/app/View/Components/Forms/Input.php @@ -34,6 +34,8 @@ class Input extends Component public ?string $canGate = null, public mixed $canResource = null, public bool $autoDisable = true, + public bool $loading = false, + public string $loadingText = 'Loading...', ) { // Handle authorization-based disabling if ($this->canGate && $this->canResource && $this->autoDisable) { diff --git a/resources/views/components/forms/input.blade.php b/resources/views/components/forms/input.blade.php index 2f96331643..f49864ddfb 100644 --- a/resources/views/components/forms/input.blade.php +++ b/resources/views/components/forms/input.blade.php @@ -22,7 +22,11 @@ @endif @endif - @if ($type === 'password') + @if ($loading) +
+ +
+ @elseif ($type === 'password')
@if (!$valuesLoaded) -
- -
+ @else @foreach ($privateKeys as $key) @can('view', $key) - -
@@ -104,14 +103,13 @@ @endif
- - - +
@else
@@ -139,6 +137,39 @@ @endforeach
@endif + + + +
+
+ + +
+ +
+
+
+ + Edit key +
+ +
+
+
+ Delete + Save changes +
+
+ @if ($selectedPrivateKeyUuid) +
+ +
+ @endif +
diff --git a/resources/views/livewire/security/private-key/show.blade.php b/resources/views/livewire/security/private-key/show.blade.php index 59cffc70fa..da9373f0f1 100644 --- a/resources/views/livewire/security/private-key/show.blade.php +++ b/resources/views/livewire/security/private-key/show.blade.php @@ -1,6 +1,8 @@ -
+
@if ($modalMode) -
+
+
@@ -34,6 +36,11 @@ Save changes
+
+ +
+
@else {{ $private_key->name }} | Private Keys | Coolify diff --git a/tests/Feature/EnvironmentVariableMultilineToggleViewTest.php b/tests/Feature/EnvironmentVariableMultilineToggleViewTest.php index c98a200caf..77df86e2d8 100644 --- a/tests/Feature/EnvironmentVariableMultilineToggleViewTest.php +++ b/tests/Feature/EnvironmentVariableMultilineToggleViewTest.php @@ -29,6 +29,8 @@ it('lazy-loads decrypted values only when opening the edit modal', function () { ->toContain('wireOpen="editorOpen"') ->toContain('$valuesLoaded') ->toContain('Loading value...'); + + expect($view)->toContain(' true, ])->set('name', 'Updated SSH key') ->call('changePrivateKey') - ->assertDispatched('securityResourceChanged') - ->assertDispatched('close-modal'); + ->assertDispatched('privateKeyUpdated') + ->assertNoRedirect(); expect($privateKey->fresh()->name)->toBe('Updated SSH key'); }); diff --git a/tests/Feature/SecurityResourceModalEditorsTest.php b/tests/Feature/SecurityResourceModalEditorsTest.php index b2cb0a5883..06d07a1676 100644 --- a/tests/Feature/SecurityResourceModalEditorsTest.php +++ b/tests/Feature/SecurityResourceModalEditorsTest.php @@ -1,11 +1,15 @@ actingAs($user); session(['currentTeam' => $team]); $this->team = $team; + Storage::fake('ssh-keys'); }); it('opens security resources in modal editors and keeps create actions in card headers', function () { @@ -40,12 +45,24 @@ it('opens security resources in modal editors and keeps create actions in card h ->toContain('toContain(':contentClicks="false"') - ->toContain('@click="modalOpen=true"') - ->not->toContain('wire:click="openEditor(') ->not->toContain('href="{{ route(\'security.'); } - expect(file_get_contents($views[0]))->toContain('>Private key
', '>Status
'); + expect(file_get_contents($views[0])) + ->toContain('>Private key', '>Status') + ->toContain('wire:click="openEditor(\'{{ $key->uuid }}\')"') + ->toContain("\$dispatch('open-private-key-editor', { name:") + ->toContain('$refs.loadingPrivateKeyName.value = $event.detail.name') + ->toContain('wire:loading.flex wire:target="openEditor"') + ->toContain('aria-label="Loading private key editor"') + ->toContain('class="w-full flex-col gap-4"') + ->toContain('toContain('') + ->toContain('') + ->toContain('') + ->not->toContain('class="flex flex-col gap-1.5 lg:col-span-2"') + ->not->toContain('animate-pulse') + ->and(substr_count(file_get_contents($views[0]), 'toBe(1); expect(file_get_contents($views[1]))->toContain('>Token', '>Provider'); expect(file_get_contents($views[2]))->toContain('>Script', '>Last updated'); @@ -82,3 +99,71 @@ it('deletes a cloud-init script from its modal editor without redirecting to a d $this->assertModelMissing($script); }); + +it('keeps the remaining private key editor populated after deleting multiple keys', function () { + $privateKeys = collect(range(1, 3))->map(fn (int $index) => PrivateKey::factory()->create([ + 'name' => "private-key-regression-marker-{$index}", + 'team_id' => $this->team->id, + 'private_key' => PrivateKey::generateNewKeyPair('ed25519')['private_key'], + ])); + $index = Livewire::test(PrivateKeyIndex::class); + + foreach ($privateKeys->take(2) as $privateKey) { + Livewire::test(PrivateKeyShow::class, [ + 'private_key_uuid' => $privateKey->uuid, + 'modalMode' => true, + ])->call('delete') + ->assertDispatched('privateKeyDeleted') + ->assertNoRedirect(); + } + + $remainingPrivateKey = $privateKeys->last(); + + $index->dispatch('securityResourceChanged') + ->assertDontSee($privateKeys->get(0)->name) + ->assertDontSee($privateKeys->get(1)->name) + ->assertSee($remainingPrivateKey->name); + + Livewire::test(PrivateKeyShow::class, [ + 'private_key_uuid' => $remainingPrivateKey->uuid, + 'modalMode' => true, + ])->assertSet('name', $remainingPrivateKey->name) + ->assertSee($remainingPrivateKey->name); +}); + +it('loads only the selected private key editor and refreshes mutations without navigation', function () { + $privateKey = PrivateKey::factory()->create([ + 'team_id' => $this->team->id, + ]); + + Livewire::test(PrivateKeyIndex::class) + ->call('openEditor', $privateKey->uuid) + ->assertSet('selectedPrivateKeyUuid', $privateKey->uuid) + ->dispatch('modalClosed') + ->assertSet('selectedPrivateKeyUuid', null) + ->dispatch('privateKeyCreated', keyId: $privateKey->id) + ->assertNoRedirect(); +}); + +it('loads the public key with the editor instead of making a follow-up request', function () { + $privateKey = PrivateKey::factory()->create([ + 'team_id' => $this->team->id, + ]); + + Livewire::test(PrivateKeyShow::class, [ + 'private_key_uuid' => $privateKey->uuid, + 'modalMode' => true, + ])->assertSet('public_key', $privateKey->getPublicKey()); + + expect(file_get_contents(resource_path('views/livewire/security/private-key/show.blade.php'))) + ->not->toContain('x-init="$wire.loadPublicKey()"'); +}); + +it('shows deletion progress in the underlying private key editor', function () { + $view = file_get_contents(resource_path('views/livewire/security/private-key/show.blade.php')); + + expect($view) + ->toContain('wire:loading.class="pointer-events-none opacity-50" wire:target="delete"') + ->toContain('wire:loading.flex wire:target="delete"') + ->toContain(''); +}); From fb2c9857f4b657d87c1e18cbfb1a2d16756e85b1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:41:08 +0200 Subject: [PATCH 10/53] fix(proxy): clear stale Traefik branch upgrade warnings (#11496) --- app/Livewire/Server/Proxy.php | 57 +++++++++- tests/Feature/TraefikVersionStateTest.php | 120 ++++++++++++++++++++++ 2 files changed, 175 insertions(+), 2 deletions(-) diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index 811a01eb19..68cb52a926 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -57,6 +57,7 @@ class Proxy extends Component $this->redirectUrl = data_get($this->server, 'proxy.redirect_url'); $this->syncData(false); $this->loadProxyConfiguration(); + $this->clearAppliedTraefikBranchWarning(); } private function syncData(bool $toModel = false): void @@ -276,6 +277,8 @@ class Proxy extends Component return null; } + $configuredBranch = $this->getConfiguredTraefikBranch(); + // Check if we have outdated info stored for this server (faster than computing) $outdatedInfo = $this->server->traefik_outdated_info; $storedCurrentVersion = ltrim((string) data_get($outdatedInfo, 'current'), 'v'); @@ -283,9 +286,15 @@ class Proxy extends Component if ($storedCurrentVersion === $detectedCurrentVersion && data_get($outdatedInfo, 'type') === 'minor_upgrade') { // Use the upgrade_target field if available (e.g., "v3.6") if (isset($outdatedInfo['upgrade_target'])) { - return str_starts_with($outdatedInfo['upgrade_target'], 'v') + $upgradeTarget = str_starts_with($outdatedInfo['upgrade_target'], 'v') ? $outdatedInfo['upgrade_target'] : "v{$outdatedInfo['upgrade_target']}"; + + if ($configuredBranch && version_compare($configuredBranch, ltrim($upgradeTarget, 'v'), '>=')) { + return null; + } + + return $upgradeTarget; } } @@ -315,9 +324,53 @@ class Proxy extends Component } } - return $newestBranch ? "v{$newestBranch}" : null; + if (! $newestBranch || ($configuredBranch && version_compare($configuredBranch, $newestBranch, '>='))) { + return null; + } + + return "v{$newestBranch}"; } catch (\Throwable $e) { return null; } } + + private function getConfiguredTraefikBranch(): ?string + { + if ($this->server->proxy->get('status') !== 'running' || $this->server->hasPendingProxyConfiguration()) { + return null; + } + + if (! is_string($this->proxySettings)) { + return null; + } + + if (! preg_match('/^\s*image:\s*[\'\"]?traefik:v?(\d+\.\d+)(?:\.\d+)?[\'\"]?\s*$/mi', $this->proxySettings, $matches)) { + return null; + } + + return $matches[1]; + } + + private function clearAppliedTraefikBranchWarning(): void + { + $outdatedInfo = $this->server->traefik_outdated_info; + + if (data_get($outdatedInfo, 'type') !== 'minor_upgrade') { + return; + } + + $configuredBranch = $this->getConfiguredTraefikBranch(); + $upgradeTarget = ltrim((string) data_get($outdatedInfo, 'upgrade_target'), 'v'); + + if (! $configuredBranch || ! $upgradeTarget || version_compare($configuredBranch, $upgradeTarget, '<')) { + return; + } + + Server::query() + ->whereKey($this->server->id) + ->where('traefik_outdated_info->type', data_get($outdatedInfo, 'type')) + ->where('traefik_outdated_info->current', data_get($outdatedInfo, 'current')) + ->where('traefik_outdated_info->upgrade_target', data_get($outdatedInfo, 'upgrade_target')) + ->update(['traefik_outdated_info' => null]); + } } diff --git a/tests/Feature/TraefikVersionStateTest.php b/tests/Feature/TraefikVersionStateTest.php index 9068683f69..3dad5423e5 100644 --- a/tests/Feature/TraefikVersionStateTest.php +++ b/tests/Feature/TraefikVersionStateTest.php @@ -35,6 +35,126 @@ it('ignores stale minor upgrade information for the detected Traefik version', f expect($component->getNewerTraefikBranchAvailableProperty())->toBeNull(); }); +it('does not offer the Traefik branch already configured on a running proxy', function () { + Cache::put('coolify:versions:all', [ + 'traefik' => [ + 'v3.7' => '3.7.8', + 'v3.6' => '3.6.23', + ], + ]); + + $server = Server::factory()->make([ + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => 'running', + ], + 'detected_traefik_version' => '3.6.23', + 'traefik_outdated_info' => [ + 'current' => '3.6.23', + 'latest' => '3.7.8', + 'type' => 'minor_upgrade', + 'upgrade_target' => 'v3.7', + ], + ]); + + $component = new Proxy; + $component->server = $server; + $component->proxySettings = <<<'YAML' +services: + traefik: + image: 'traefik:v3.7' +YAML; + + expect($component->getNewerTraefikBranchAvailableProperty())->toBeNull(); +}); + +it('still offers a newer Traefik branch than the configured image', function () { + Cache::put('coolify:versions:all', [ + 'traefik' => [ + 'v3.7' => '3.7.8', + 'v3.6' => '3.6.23', + ], + ]); + + $server = Server::factory()->make([ + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => 'running', + ], + 'detected_traefik_version' => '3.6.23', + ]); + + $component = new Proxy; + $component->server = $server; + $component->proxySettings = 'services:'.PHP_EOL.' traefik:'.PHP_EOL.' image: traefik:v3.6'; + + expect($component->getNewerTraefikBranchAvailableProperty())->toBe('v3.7'); +}); + +it('clears the stale minor warning after the configured branch is applied', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => 'running', + 'last_saved_proxy_configuration' => <<<'YAML' +services: + traefik: + image: traefik:v3.7 +YAML, + ], + 'detected_traefik_version' => '3.6.23', + 'traefik_outdated_info' => [ + 'current' => '3.6.23', + 'latest' => '3.7.8', + 'type' => 'minor_upgrade', + 'upgrade_target' => 'v3.7', + ], + ]); + + $component = new Proxy; + $component->server = $server; + $component->mount(); + + expect($server->refresh()->traefik_outdated_info)->toBeNull(); +}); + +it('preserves a newer Traefik warning stored after the warning was inspected', function () { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => 'running', + ], + 'detected_traefik_version' => '3.6.23', + 'traefik_outdated_info' => [ + 'current' => '3.6.23', + 'latest' => '3.7.8', + 'type' => 'minor_upgrade', + 'upgrade_target' => 'v3.7', + ], + ]); + + $component = new Proxy; + $component->server = $server; + $component->proxySettings = 'services:'.PHP_EOL.' traefik:'.PHP_EOL.' image: traefik:v3.7'; + + $newerWarning = [ + 'current' => '3.7.8', + 'latest' => '3.8.1', + 'type' => 'minor_upgrade', + 'upgrade_target' => 'v3.8', + ]; + Server::query()->whereKey($server->id)->update(['traefik_outdated_info' => $newerWarning]); + + $method = new ReflectionMethod($component, 'clearAppliedTraefikBranchWarning'); + $method->invoke($component); + + expect($server->refresh()->traefik_outdated_info)->toBe($newerWarning); +}); + it('does not mark stale Traefik outdated information as a current warning', function () { $server = Server::factory()->make([ 'proxy' => ['type' => ProxyTypes::TRAEFIK->value], From 87e7a9a9c01dceef4636e8fe76d301b5c55a3dd7 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:53:23 +0200 Subject: [PATCH 11/53] chore(releases): bump helper version to 1.0.16 --- config/constants.php | 2 +- other/nightly/versions.json | 2 +- versions.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/constants.php b/config/constants.php index ca05db48f4..4701f1f6e2 100644 --- a/config/constants.php +++ b/config/constants.php @@ -3,7 +3,7 @@ return [ 'coolify' => [ 'version' => env('COOLIFY_VERSION') ?: '4.3.11', - 'helper_version' => '1.0.15', + 'helper_version' => '1.0.16', 'realtime_version' => '1.0.17', 'railpack_version' => '0.23.0', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/other/nightly/versions.json b/other/nightly/versions.json index d4e5b5c8c9..2e178b3a99 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -7,7 +7,7 @@ "version": "4.4-rc.1" }, "helper": { - "version": "1.0.15" + "version": "1.0.16" }, "realtime": { "version": "1.0.17" diff --git a/versions.json b/versions.json index d4e5b5c8c9..2e178b3a99 100644 --- a/versions.json +++ b/versions.json @@ -7,7 +7,7 @@ "version": "4.4-rc.1" }, "helper": { - "version": "1.0.15" + "version": "1.0.16" }, "realtime": { "version": "1.0.17" From 44e4f071b1e3340c516cab2cc6d8c4738d47087d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:56:06 +0200 Subject: [PATCH 12/53] ci(helper): enable manual workflow dispatch --- .github/workflows/coolify-helper.yml | 1 + tests/Unit/ProductionImageWorkflowTest.php | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/coolify-helper.yml b/.github/workflows/coolify-helper.yml index f5d0c3f0ad..de0cf3b8d7 100644 --- a/.github/workflows/coolify-helper.yml +++ b/.github/workflows/coolify-helper.yml @@ -1,6 +1,7 @@ name: Coolify Helper Image on: + workflow_dispatch: push: branches: [ "main" ] paths: diff --git a/tests/Unit/ProductionImageWorkflowTest.php b/tests/Unit/ProductionImageWorkflowTest.php index 2fafa678e8..d279087c2a 100644 --- a/tests/Unit/ProductionImageWorkflowTest.php +++ b/tests/Unit/ProductionImageWorkflowTest.php @@ -77,6 +77,7 @@ it('prevents the stable helper workflow from publishing an existing version', fu $workflow = file_get_contents(dirname(__DIR__, 2).'/.github/workflows/coolify-helper.yml'); expect($workflow) + ->toContain('workflow_dispatch:') ->toContain('check-version:') ->toContain('needs: check-version') ->toContain('VERSION="${BASE_VERSION}"') From 07d6c3aeeb4a6e34ebcd3975b125d08cfacc61f3 Mon Sep 17 00:00:00 2001 From: Kevin Dench Date: Wed, 26 Aug 2026 06:53:42 -0600 Subject: [PATCH 13/53] fix(server): reset unreachable_count after successful connection check (#11417) --- app/Jobs/ServerConnectionCheckJob.php | 5 ++++- .../ServerConnectionCheckIsolationTest.php | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php index fe7a20972c..97d211d247 100644 --- a/app/Jobs/ServerConnectionCheckJob.php +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -100,7 +100,10 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue ]); if ($this->server->unreachable_count > 0) { - $this->server->update(['unreachable_count' => 0]); + // Direct assignment: unreachable_count is not mass-assignable, + // so update() would silently drop the reset. + $this->server->unreachable_count = 0; + $this->server->save(); } $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, true); diff --git a/tests/Feature/ServerConnectionCheckIsolationTest.php b/tests/Feature/ServerConnectionCheckIsolationTest.php index d098c39b55..895304c7d1 100644 --- a/tests/Feature/ServerConnectionCheckIsolationTest.php +++ b/tests/Feature/ServerConnectionCheckIsolationTest.php @@ -78,3 +78,21 @@ it('does not call cloud provider APIs during an SSH connection check', function Http::assertNothingSent(); }); + +it('resets unreachable_count after a successful connection check', function () { + $server = createServerForConnectionIsolationTest(['ip' => '203.0.113.10']); + // unreachable_count is not mass-assignable, so it must be set directly. + $server->unreachable_count = 5; + $server->save(); + + Process::fake([ + '*' => Process::result( + output: '{"Server":{"Version":"27.0.0"}}', + exitCode: 0, + ), + ]); + + (new ServerConnectionCheckJob($server->fresh(), disableMux: false))->handle(); + + expect($server->fresh()->unreachable_count)->toBe(0); +}); From 22ca3216e80bcce4e41a9a99d4876340ea2ea52b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=8F=94=EF=B8=8F=20Peak?= <122374094+peaklabs-dev@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:27:48 +0200 Subject: [PATCH 14/53] fix(ui): monaco editor placeholder alignment (#11514) --- .../components/forms/monaco-editor.blade.php | 27 ++----------------- 1 file changed, 2 insertions(+), 25 deletions(-) diff --git a/resources/views/components/forms/monaco-editor.blade.php b/resources/views/components/forms/monaco-editor.blade.php index b0d4adb14e..69466f7daa 100644 --- a/resources/views/components/forms/monaco-editor.blade.php +++ b/resources/views/components/forms/monaco-editor.blade.php @@ -3,8 +3,6 @@ monacoVersion: '0.52.2', monacoContent: @entangle($id), monacoLanguage: '', - monacoPlaceholder: true, - monacoPlaceholderText: 'Start typing here', monacoLoader: true, monacoFontSize: '15px', monacoId: $id('monaco-editor'), @@ -14,20 +12,7 @@ monacoEditor(editor) { editor.onDidChangeModelContent((e) => { this.monacoContent = editor.getValue(); - this.updatePlaceholder(editor.getValue()); }); - editor.onDidBlurEditorWidget(() => { - this.updatePlaceholder(editor.getValue()); - }); - editor.onDidFocusEditorWidget(() => { - this.updatePlaceholder(editor.getValue()); - }); - }, - updatePlaceholder(value) { - this.monacoPlaceholder = value === ''; - }, - monacoEditorFocus() { - document.getElementById(this.monacoId).dispatchEvent(new CustomEvent('monaco-editor-focused', { detail: { monacoId: this.monacoId } })); }, monacoEditorAddLoaderScriptToHead() { // Use a global flag to prevent duplicate script loading @@ -111,6 +96,7 @@ lineNumbersMinChars: 3, automaticLayout: true, language: '{{ $language }}', + placeholder: 'Start typing here', domReadOnly: '{{ $readonly ?? false }}', contextmenu: '!{{ $readonly ?? false }}', renderLineHighlight: 'none', @@ -143,13 +129,8 @@ }); monacoEditor(editor); - - document.getElementById(monacoId).editor = editor; - document.getElementById(monacoId).addEventListener('monaco-editor-focused', (event) => { - editor.focus(); - }); - updatePlaceholder(editor.getValue()); + document.getElementById(monacoId).editor = editor; @if ($autofocus) // Auto-focus the editor @@ -172,10 +153,6 @@
-
From 65a7520553a45105cd1f6485d578ac0381bdffc8 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:10:54 +0200 Subject: [PATCH 15/53] test: fix instance settings creation --- tests/Feature/Auth/LoginRateLimitIPTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Feature/Auth/LoginRateLimitIPTest.php b/tests/Feature/Auth/LoginRateLimitIPTest.php index e1b85ae5c0..04e6925f4d 100644 --- a/tests/Feature/Auth/LoginRateLimitIPTest.php +++ b/tests/Feature/Auth/LoginRateLimitIPTest.php @@ -8,7 +8,7 @@ use Illuminate\Support\Facades\RateLimiter; uses(RefreshDatabase::class); beforeEach(function () { - InstanceSettings::updateOrCreate(['id' => 0]); + InstanceSettings::forceCreate(['id' => 0]); RateLimiter::clear('login'); $this->user = User::factory()->create([ From 3842bc244214a9411cca1a07e72b562c57838338 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:12:01 +0200 Subject: [PATCH 16/53] fix(auth): rate limit by proxy-aware client ip instead of REMOTE_ADDR --- app/Actions/Fortify/CreateNewUser.php | 10 +---- app/Providers/FortifyServiceProvider.php | 42 --------------------- app/Providers/RouteServiceProvider.php | 29 ++++++++++++++ tests/Feature/RegistrationRateLimitTest.php | 37 ++++++++++++++++++ 4 files changed, 67 insertions(+), 51 deletions(-) diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 44a03c17da..ea249ad4f3 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -4,7 +4,6 @@ namespace App\Actions\Fortify; use App\Models\Team; use App\Models\User; -use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Validator; @@ -22,8 +21,6 @@ class CreateNewUser implements CreatesNewUsers private const REGISTRATION_EMAIL_IDENTITY_DECAY_SECONDS = 3600; - public function __construct(private readonly Request $request) {} - /** * Validate and create a newly registered user. * @@ -95,7 +92,7 @@ class CreateNewUser implements CreatesNewUsers { $keys = [ [ - 'key' => 'registration:ip:'.sha1($this->realIp()), + 'key' => 'registration:ip:'.sha1((string) request()->ip()), 'max' => self::REGISTRATION_IP_MAX_ATTEMPTS, 'decay' => self::REGISTRATION_IP_DECAY_SECONDS, ], @@ -120,9 +117,4 @@ class CreateNewUser implements CreatesNewUsers RateLimiter::hit($limit['key'], $limit['decay']); } } - - private function realIp(): string - { - return $this->request->server('REMOTE_ADDR') ?? $this->request->ip(); - } } diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 65d9687744..ce16e617d7 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -9,10 +9,8 @@ use App\Actions\Fortify\UpdateUserProfileInformation; use App\Models\OauthSetting; use App\Models\TeamInvitation; use App\Models\User; -use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; -use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Laravel\Fortify\Contracts\RegisterResponse; use Laravel\Fortify\Fortify; @@ -122,45 +120,5 @@ class FortifyServiceProvider extends ServiceProvider Fortify::twoFactorChallengeView(function () { return view('auth.two-factor-challenge'); }); - - RateLimiter::for('force-password-reset', function (Request $request) { - return Limit::perMinute(15)->by($request->user()->id); - }); - - RateLimiter::for('forgot-password', function (Request $request) { - // Use real client IP (not spoofable forwarded headers) - $realIp = $request->server('REMOTE_ADDR') ?? $request->ip(); - - $limits = [ - Limit::perMinutes(10, 3)->by('forgot-password:ip:'.sha1($realIp)), - ]; - - $emailIdentity = normalize_email_identity($request->input('email')); - if ($emailIdentity !== null) { - $limits[] = Limit::perHour(3)->by('forgot-password:email-identity:'.sha1($emailIdentity)); - } - - return $limits; - }); - - RateLimiter::for('login', function (Request $request) { - $email = (string) $request->email; - // Use email + real client IP (not spoofable forwarded headers) - // server('REMOTE_ADDR') gives the actual connecting IP before proxy headers - $realIp = $request->server('REMOTE_ADDR') ?? $request->ip(); - - return Limit::perMinute(5)->by($email.'|'.$realIp); - }); - - RateLimiter::for('magic-link', function (Request $request) { - $realIp = $request->server('REMOTE_ADDR') ?? $request->ip(); - $token = (string) $request->input('token'); - - return Limit::perMinute(5)->by(hash('sha256', $token.'|'.$realIp)); - }); - - RateLimiter::for('two-factor', function (Request $request) { - return Limit::perMinute(5)->by($request->session()->get('login.id')); - }); } } diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 4068572c81..e07d39a084 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -58,5 +58,34 @@ class RouteServiceProvider extends ServiceProvider RateLimiter::for('feedback', function (Request $request) { return Limit::perMinute(3)->by($request->user()?->id ?: $request->ip()); }); + + RateLimiter::for('login', function (Request $request) { + return Limit::perMinute(5)->by((string) $request->email.'|'.$request->ip()); + }); + + RateLimiter::for('two-factor', function (Request $request) { + return Limit::perMinute(5)->by($request->session()->get('login.id')); + }); + + RateLimiter::for('forgot-password', function (Request $request) { + $limits = [ + Limit::perMinutes(10, 3)->by('forgot-password:ip:'.sha1((string) $request->ip())), + ]; + + $emailIdentity = normalize_email_identity($request->input('email')); + if ($emailIdentity !== null) { + $limits[] = Limit::perHour(3)->by('forgot-password:email-identity:'.sha1($emailIdentity)); + } + + return $limits; + }); + + RateLimiter::for('magic-link', function (Request $request) { + return Limit::perMinute(5)->by(hash('sha256', (string) $request->input('token').'|'.$request->ip())); + }); + + RateLimiter::for('force-password-reset', function (Request $request) { + return Limit::perMinute(15)->by($request->user()->id); + }); } } diff --git a/tests/Feature/RegistrationRateLimitTest.php b/tests/Feature/RegistrationRateLimitTest.php index 7130ed36e6..5007c41d8d 100644 --- a/tests/Feature/RegistrationRateLimitTest.php +++ b/tests/Feature/RegistrationRateLimitTest.php @@ -72,6 +72,43 @@ it('rate limits dotted plus-address variants of the same email identity across i ->assertTooManyRequests(); }); +it('keeps clients behind the same reverse proxy in separate ip rate limit buckets', function () { + foreach (range(1, 3) as $attempt) { + $this->withServerVariables(['REMOTE_ADDR' => '172.18.0.2']) + ->withHeaders(['X-Forwarded-For' => '203.0.113.50']) + ->post('/register', [ + 'name' => "Proxied User {$attempt}", + 'email' => "proxied{$attempt}@example.com", + 'password' => 'Password1!@', + 'password_confirmation' => 'Password1!@', + ]) + ->assertRedirect(); + + auth()->logout(); + $this->flushSession(); + } + + $this->withServerVariables(['REMOTE_ADDR' => '172.18.0.2']) + ->withHeaders(['X-Forwarded-For' => '203.0.113.50']) + ->post('/register', [ + 'name' => 'Blocked User', + 'email' => 'blocked-proxied@example.com', + 'password' => 'Password1!@', + 'password_confirmation' => 'Password1!@', + ]) + ->assertTooManyRequests(); + + $this->withServerVariables(['REMOTE_ADDR' => '172.18.0.2']) + ->withHeaders(['X-Forwarded-For' => '203.0.113.51']) + ->post('/register', [ + 'name' => 'Other Client', + 'email' => 'other-proxied@example.com', + 'password' => 'Password1!@', + 'password_confirmation' => 'Password1!@', + ]) + ->assertRedirect(); +}); + it('keeps distinct dotted and plus-addressed mailboxes in separate rate limit buckets on ordinary domains', function () { $registrationIpKey = 'registration:ip:'.sha1('127.0.0.1'); $emails = [ From bf20c8b287d9211195a159d48bd6535dc15b0ac3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:25:28 +0200 Subject: [PATCH 17/53] fix(auth): use Cloudflare client IP for authentication rate limits --- app/Actions/Fortify/CreateNewUser.php | 2 +- app/Providers/RouteServiceProvider.php | 6 ++-- bootstrap/helpers/auth.php | 14 ++++++++ tests/Feature/Auth/LoginRateLimitIPTest.php | 26 ++++++++++++++ tests/Feature/AuthRateLimitClientIpTest.php | 36 +++++++++++++++++++ tests/Feature/ForgotPasswordRateLimitTest.php | 2 ++ tests/Feature/RegistrationRateLimitTest.php | 2 ++ 7 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 bootstrap/helpers/auth.php create mode 100644 tests/Feature/AuthRateLimitClientIpTest.php diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index ea249ad4f3..0fcb11df22 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -92,7 +92,7 @@ class CreateNewUser implements CreatesNewUsers { $keys = [ [ - 'key' => 'registration:ip:'.sha1((string) request()->ip()), + 'key' => 'registration:ip:'.sha1(auth_rate_limit_ip(request())), 'max' => self::REGISTRATION_IP_MAX_ATTEMPTS, 'decay' => self::REGISTRATION_IP_DECAY_SECONDS, ], diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index e07d39a084..79139c6b93 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -60,7 +60,7 @@ class RouteServiceProvider extends ServiceProvider }); RateLimiter::for('login', function (Request $request) { - return Limit::perMinute(5)->by((string) $request->email.'|'.$request->ip()); + return Limit::perMinute(5)->by((string) $request->email.'|'.auth_rate_limit_ip($request)); }); RateLimiter::for('two-factor', function (Request $request) { @@ -69,7 +69,7 @@ class RouteServiceProvider extends ServiceProvider RateLimiter::for('forgot-password', function (Request $request) { $limits = [ - Limit::perMinutes(10, 3)->by('forgot-password:ip:'.sha1((string) $request->ip())), + Limit::perMinutes(10, 3)->by('forgot-password:ip:'.sha1(auth_rate_limit_ip($request))), ]; $emailIdentity = normalize_email_identity($request->input('email')); @@ -81,7 +81,7 @@ class RouteServiceProvider extends ServiceProvider }); RateLimiter::for('magic-link', function (Request $request) { - return Limit::perMinute(5)->by(hash('sha256', (string) $request->input('token').'|'.$request->ip())); + return Limit::perMinute(5)->by(hash('sha256', (string) $request->input('token').'|'.auth_rate_limit_ip($request))); }); RateLimiter::for('force-password-reset', function (Request $request) { diff --git a/bootstrap/helpers/auth.php b/bootstrap/helpers/auth.php new file mode 100644 index 0000000000..7580fc1e06 --- /dev/null +++ b/bootstrap/helpers/auth.php @@ -0,0 +1,14 @@ +header('CF-Connecting-IP'); + + if (isCloud() && is_string($cloudflareIp) && filter_var($cloudflareIp, FILTER_VALIDATE_IP) !== false) { + return $cloudflareIp; + } + + return (string) $request->ip(); +} diff --git a/tests/Feature/Auth/LoginRateLimitIPTest.php b/tests/Feature/Auth/LoginRateLimitIPTest.php index 04e6925f4d..9c2214c58a 100644 --- a/tests/Feature/Auth/LoginRateLimitIPTest.php +++ b/tests/Feature/Auth/LoginRateLimitIPTest.php @@ -8,6 +8,8 @@ use Illuminate\Support\Facades\RateLimiter; uses(RefreshDatabase::class); beforeEach(function () { + config()->set('app.maintenance.store', 'array'); + InstanceSettings::forceCreate(['id' => 0]); RateLimiter::clear('login'); @@ -66,3 +68,27 @@ test('successful login is still possible within rate limit', function () { $response->assertRedirect(); expect($response->status())->not->toBe(429); }); + +test('cloud login rate limits use the Cloudflare client ip', function () { + config()->set('constants.coolify.self_hosted', false); + + foreach (range(1, 5) as $attempt) { + $this->withHeader('CF-Connecting-IP', '2001:db8::10') + ->withHeader('X-Forwarded-For', '2001:db8::10, 108.162.221.29') + ->withServerVariables(['REMOTE_ADDR' => '10.0.0.5']) + ->post('/login', [ + 'email' => 'test@example.com', + 'password' => 'wrong-password', + ]) + ->assertRedirect(); + } + + $this->withHeader('CF-Connecting-IP', '2001:db8::20') + ->withHeader('X-Forwarded-For', '2001:db8::20, 108.162.221.29') + ->withServerVariables(['REMOTE_ADDR' => '10.0.0.5']) + ->post('/login', [ + 'email' => 'test@example.com', + 'password' => 'wrong-password', + ]) + ->assertRedirect(); +}); diff --git a/tests/Feature/AuthRateLimitClientIpTest.php b/tests/Feature/AuthRateLimitClientIpTest.php new file mode 100644 index 0000000000..ff32dab6a9 --- /dev/null +++ b/tests/Feature/AuthRateLimitClientIpTest.php @@ -0,0 +1,36 @@ +set('constants.coolify.self_hosted', false); + + $request = Request::create('/', server: [ + 'REMOTE_ADDR' => '10.0.0.5', + 'HTTP_CF_CONNECTING_IP' => '2001:db8::10', + ]); + + expect(auth_rate_limit_ip($request))->toBe('2001:db8::10'); +}); + +it('falls back to the resolved request ip when the Cloudflare header is invalid', function () { + config()->set('constants.coolify.self_hosted', false); + + $request = Request::create('/', server: [ + 'REMOTE_ADDR' => '203.0.113.10', + 'HTTP_CF_CONNECTING_IP' => 'invalid', + ]); + + expect(auth_rate_limit_ip($request))->toBe('203.0.113.10'); +}); + +it('ignores the Cloudflare header on self-hosted instances', function () { + config()->set('constants.coolify.self_hosted', true); + + $request = Request::create('/', server: [ + 'REMOTE_ADDR' => '203.0.113.20', + 'HTTP_CF_CONNECTING_IP' => '2001:db8::20', + ]); + + expect(auth_rate_limit_ip($request))->toBe('203.0.113.20'); +}); diff --git a/tests/Feature/ForgotPasswordRateLimitTest.php b/tests/Feature/ForgotPasswordRateLimitTest.php index 02dbce6507..20fbd22596 100644 --- a/tests/Feature/ForgotPasswordRateLimitTest.php +++ b/tests/Feature/ForgotPasswordRateLimitTest.php @@ -6,6 +6,8 @@ use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); beforeEach(function () { + config()->set('app.maintenance.store', 'array'); + InstanceSettings::query()->forceCreate(['id' => 0]); }); diff --git a/tests/Feature/RegistrationRateLimitTest.php b/tests/Feature/RegistrationRateLimitTest.php index 5007c41d8d..4107e4a81d 100644 --- a/tests/Feature/RegistrationRateLimitTest.php +++ b/tests/Feature/RegistrationRateLimitTest.php @@ -8,6 +8,8 @@ use Illuminate\Support\Facades\RateLimiter; uses(RefreshDatabase::class); beforeEach(function () { + config()->set('app.maintenance.store', 'array'); + InstanceSettings::query()->forceCreate([ 'id' => 0, 'is_registration_enabled' => true, From 1f412c7145c815848e21def3321c86ee226554d6 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:28:20 +0200 Subject: [PATCH 18/53] fix(auth): queue verification emails for cloud registrations Add a high-priority job with retries and coverage confirming cloud registrations dispatch it. --- app/Actions/Fortify/CreateNewUser.php | 3 ++- app/Jobs/SendVerificationEmailJob.php | 29 +++++++++++++++++++++ tests/Feature/RegistrationRateLimitTest.php | 23 ++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 app/Jobs/SendVerificationEmailJob.php diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 0fcb11df22..44602f0619 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -2,6 +2,7 @@ namespace App\Actions\Fortify; +use App\Jobs\SendVerificationEmailJob; use App\Models\Team; use App\Models\User; use Illuminate\Support\Facades\Hash; @@ -74,7 +75,7 @@ class CreateNewUser implements CreatesNewUsers ]); $team = $user->teams()->first(); if (isCloud()) { - $user->sendVerificationEmail(); + SendVerificationEmailJob::dispatch($user); } else { $user->markEmailAsVerified(); } diff --git a/app/Jobs/SendVerificationEmailJob.php b/app/Jobs/SendVerificationEmailJob.php new file mode 100644 index 0000000000..b9d2c8e11e --- /dev/null +++ b/app/Jobs/SendVerificationEmailJob.php @@ -0,0 +1,29 @@ +onQueue('high'); + } + + /** + * Execute the job. + */ + public function handle(): void + { + $this->user->sendVerificationEmail(); + } +} diff --git a/tests/Feature/RegistrationRateLimitTest.php b/tests/Feature/RegistrationRateLimitTest.php index 4107e4a81d..3f7c4920db 100644 --- a/tests/Feature/RegistrationRateLimitTest.php +++ b/tests/Feature/RegistrationRateLimitTest.php @@ -1,8 +1,10 @@ set('constants.coolify.self_hosted', false); + Queue::fake(); + + $this->withHeader('CF-Connecting-IP', '2001:db8::30') + ->post('/register', [ + 'name' => 'Cloud User', + 'email' => 'cloud-user@example.com', + 'password' => 'Password1!@', + 'password_confirmation' => 'Password1!@', + ]) + ->assertRedirect(); + + $user = User::query()->where('email', 'cloud-user@example.com')->firstOrFail(); + + Queue::assertPushed( + SendVerificationEmailJob::class, + fn (SendVerificationEmailJob $job) => $job->user->is($user) + ); +}); From 29c7b043f39c65a23d146c9013aad630cb6c80d9 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:28:54 +0200 Subject: [PATCH 19/53] chore(release): bump Coolify version to 4.3.12 --- config/constants.php | 2 +- other/nightly/versions.json | 2 +- versions.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/constants.php b/config/constants.php index 4701f1f6e2..b6fac21d19 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => env('COOLIFY_VERSION') ?: '4.3.11', + 'version' => env('COOLIFY_VERSION') ?: '4.3.12', 'helper_version' => '1.0.16', 'realtime_version' => '1.0.17', 'railpack_version' => '0.23.0', diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 2e178b3a99..a9c5daea6a 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.3.11" + "version": "4.3.12" }, "nightly": { "version": "4.4-rc.1" diff --git a/versions.json b/versions.json index 2e178b3a99..a9c5daea6a 100644 --- a/versions.json +++ b/versions.json @@ -1,7 +1,7 @@ { "coolify": { "v4": { - "version": "4.3.11" + "version": "4.3.12" }, "nightly": { "version": "4.4-rc.1" From c2a23466cac3c148ecb8a4a30616bcfaf4c39b28 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:39:38 +0200 Subject: [PATCH 20/53] feat(services): add Executor one-click deployment template (#11530) --- public/svgs/executor.png | Bin 0 -> 176782 bytes templates/compose/executor.yaml | 35 +++++++++++++++ templates/service-templates-latest.json | 18 ++++++++ templates/service-templates.json | 18 ++++++++ tests/Unit/ExecutorServiceTemplateTest.php | 49 +++++++++++++++++++++ 5 files changed, 120 insertions(+) create mode 100644 public/svgs/executor.png create mode 100644 templates/compose/executor.yaml create mode 100644 tests/Unit/ExecutorServiceTemplateTest.php diff --git a/public/svgs/executor.png b/public/svgs/executor.png new file mode 100644 index 0000000000000000000000000000000000000000..a7cc57de9f1992625720221a1004d4d023fcc5b4 GIT binary patch literal 176782 zcmeFaXIPWj_C5>*f=F>9qaam5u>&eikfNd}D4^1m7DWY-DpI6putX71Q9+TS!T<@9 zAVUiRQ7JZxVklA)0YyRyEp(Fq4mCi+c|W`#e_v*D=DMC|uf5j2?sc!V_rtkuY^W!= zQfws$2Z!L%Bf2L!I97n4D>zm_!M|Q7S1W>laeAE8JHU}%A<@CXv7Y0o?tT*=&aNiD z|B~$YCv@hNemN^(x1yQ{vgMPL=(~MLnLXiF;(x>5LjOL|uR`+Cd#e%&@J( zc$WOQuMm}DbVhr3{FDFXZi#1jK7um_zMA%pZ=kQ%&umqyl=-ASb%OjWlQ=#`rl&TW z2BeJXQuD}Z*pNZZb%-tm+>BD<|{Bk&z6qWprTG9ejm zE!znlgd!8VAB|SUj2pB#`y`C0WOB4SJ$YC5$g;-i*_mH1Y-NpgOBs~Ltpi3(CK{Wq! zM8nC0@y06aif~@7=MjAlcid6<_KTZsCr(k5Gb5paXNOjV-ZJ-&S6H2U+_U`ns6vy- zrsY%%yn`qGOp~wHs81!C*S@Tq{$oOoHf(DDq;dEUXM}CtJ*d3(MmHB~N4*`CmOl2r zzEv?0Hx@T~lq=aJ2NiBRMjy!KST=-0XGqcHwNXz<^yg$%`$!tyE_-0)edfoKOf-MH z-jfDIAx@*JAm=l^IlNlgt6W;Oca$7ts*oCIqKUXZHg?!6CnXl2VaK)eB zg!=|#^t}o(%K!nd&)4Xn^^u-Hwnv+rP~s^q(v>38dPv)^Z3*%AFA*Ioifehh;wFPy z-;noYmhrvG^R4MR0f|U2eW~5E&sO(8k0e?|$GPPMq|<9W6-7z!F`qP{w$u+45tYZs z6-@0O5anqdhAmI=!_62yNR+(L$YJWo(T|+1yEa6m8NPBk>D}`12>K+prp*3`~n^E zf+9oo*EXWdNM3~UTD8Kl@X>*sGLPIKSCtO>WAY%GOK-@s4gUNV^l>ht?+C&J9q~m< zx|fJbHd*ydK|swtjBZP=hAqQ{b&80WsK|EVNp0iFiivd%b}#4$geT>$Mfp4!hI@sM zXBmo!3R>Y5N$&}t(ebHL#!acXikp+R^&&FMfZc5qcec4IrqER-k3Lv0M6WfAPbs|g zf~Z?`J5bO8UL<%|kFN_=xZ6^ow}QM1MK^9jK0wkIR$q(LHtk{a|*`h z^L+PiBK7wT_A!=|bBnCsNMB{HkkV`!@KQ`evpf7k0;iyZ4urP>pI$EuDKsv4K2}1u zAt=iYwWB_>r(7o_UJW%GNKh~dvW`97ty1rr5du{L?@Bub8ArLANuzA6H2=j}dv@KN zNNo>gZ`^5RHO1E-q+XfhU z4mg(GnHcTd&7Om9?R0xYt{?=Z*;of;%yd#no>Up`ZIBIue*A3cXqPl)4*Z##`{dZ`r=`+@ONvwTD-I>KDxm6Uap z!%>>k&~84qujBo@PDNX4^m(T2gZR^vcAhT3)aZmbjy-QXCJDg2Ub-q4J!VC<&*X8{ zwrog_a?S?I+XMu_#W~XvL~U^%p09vxIQS* zZyj6QA`H`{%rtX+Z9NfuLBauYV~iL&ko1)mr4R!pWXwhW-+}4sN$NGjYuUdeY;~>| zHKCrn!~;dQj&*%U*FWB`Ce(2M&y?fc5}>931ZSd(o@wrG|448~$BI*V-(U{1(`#vk zc(tb^S_D^fx<$bI>qUG)8h*+QtKbL4L?Z;$3VaDpXYocAG~a%5P{Yra>|Bn>`dd_Q z6)|tDq~4aEJ?5BIt6O>V*r0@ieM*q)FqVO?OAMc%r#>LxLPgf}i*v+>va6mJfe1;0 zx0z9jjdkpaFj?gB-BUyc%-Z1v5vh>+JkU(8&n)Zrw^f#^ZVh4gn&@c!akO*_En%#^ z-i01|e8h4lXkr#*=#Xjq7bD8LWiMdv6_)w7#v6HY9m2B>cu0WUSB4T3& zroyHJLDz%Wdl2HyB4Xo*JlZxEcDmnZ6E`>yPrA0BVQ<72UYa(=A}CaH#|R@&5)dDO z4490yEyx@zCl3*Z)=V^)ZDLd95RYKF7d?sO98}QLRf7$Lyr=o<1(;@Y2BnCpJ%bLJ z*_&cgrA{^ZTqqR3vVol`w|NXxq)r$3d%dA_c#Rr)*|+~Z#x+_X2b+pJ=^eQ#^Au;G zUpBIrM#zd9@sm}EW#i!;Vfe3vt(muJwyCs2b&Ocq*wZ&>r}F~Tu`^0o`@)po3j|q| z`@4H=)fhaCI~>(xU4R|a&1$$_#ItG&$KWkaIuN`2KUGmVru0jNEBd09$7rR=8`!XT zBy2<*6_2957`tOlle?w@e_+4UD{M@{OZqZ!jzu?A{)wjVpO*xRxp%6U;ctySBKmAn zLg)b8z;l4y-mW)p3x8LeSTx17z{UKH_-68#`e6l38m*FC%G(zZVax6%W`-nn!mw#& zEal&ZEPTj>*VXi)a6<}a*c?>&V5m?sqAw*FV;-bw`FC$F8M=MgI)71 zf=@)CD$V_ljWsl-PE`@$;ycQ2XQ@V+m&d4NDFR_=K=Xb)ySEr@Rg{|yO7JzM{&MVk zpz+MwD^Nju3OT@@$4LkS)GG5zRL^P2{D;KyYhPJ|NAWbsg)AU)CM(Gjk+ik@ECzuh zV8E|RCm(I}#gN-4&@;!e{d|$-)t{_ifvw`wKv7VEE-DF=HROGfG&$d6gHvw+>EL!l zuq#$usN+BKcgKbULCNhUtWyEMk=x~jrn6UKcLSPVpx^fAdiraLQ(d0wCoQM^tAyHXEI5~~0Q~aOvz#@ zcXhbFBfeNQ>)^+Q2y22KnwVyoo-JSEon!+h>a4aU@c>@|uzhL3{zG`HUH2QiS$Y9_ z00Z>Eu!&J@0L6Q<{zb4mIg$;@SX@EusvDOiP)y@fN_S;IN7u~)E& zx9q1l7x=TZ;@C-&9HG*bf+}^#E_fQ$G>p18ZAFToxrW@R_3KbMmX3I^;%=*T&>LSn zWiQ`#ZT9nfP{I~LOJ61!V&ZI@vVUZ6BvO2zuyVs+7MDPWf6E+`Qg|{f)_PpLjp|`F zQ-&CXGE|FK+eto1P%u?Z&rToou9OdF$=UpJie3(}`!uqtPY9dQnsSpneW%LMbP`*H zKufotbbTeIsUb;nBs}IB*Q)!>l5z3+EAGCn?KFwN&@ylif>58~9LSf-`gJKYYyfgu z(eRPFpO*~&^F@BHp5HLe?e5w?S>})h2k6_z&FKYWC|O!}S7z_Q=^SR105?eMf2k2B z^i*8xDbBSdp|=LC+6;IHw~MlN3%xabMD;__TR^t~Kd5sgxdzB9Sa0d5ESI4Lj)E2zSLwLiVmoJo1AM=@Xv7M&Oka1;c=8{?iMaYGmK=+Jx6%pSV+`up z3t5_AzrvsO^fldFg9BbjM+QFvk6_Whm0f=IW#;BoWblmwO7A;XlwzTW5Jqn{rKHR6 zT4cyaanWS1XO;yoa0be2abv7J5sd$|@~-5GRZmyl2_(1#g-jS|eQ+&HmW-b=0C?2u z1SB85%2eC7sVbFYyI)W%D;tg(f)ZFj-b&y_?Gp7p1W5{v3!ANipz5HMd#U6~dm_Jf zBJNXL57bJLRqk%zfX&3!`sdJVyM}W9ynQ#9`bvs@&><6Pbex%W8L+{O!|PuS8d%hMtV5_F?8zBmtp(8E#HZCvQew zHnCjWpNIoxNXQ|@pOE)O`Lv6QhLewY_>AeWSijKGKhbY?^(~!jyp)y{VYPmfe2cY>rUNHNR}LM z{TgyZdTt*&5Ca0fgkXujWu}KKVntr?r(6wa4bz~Lv6t_8r$VopUub{YiRC{v=ZiFJ zh#xvAHO{Qb;c1Rq`coDg801d3nLI-ECe-abU-bwvpB$hpp%Izrc2lB_b8Pd@i0v#2 z0`>=&C91RB8xm%{-_HBqej6y0^$e|AfoV3@OjLKta=m6Hxiq&B1QF5v02gD;{IQc+ zmBmFj<|H$ttVLUa3?DX&NDar7a-C&4INos^pH2Qw&LZ$YeKxD&FK;F~QrZS+zCp8b zRQnFPNK$2G&!xbZxQ`DS8+=$*CAsa`OkX$ZFrn_3M+zh)P+DU)F{Wk{UJzR}&BL?P z@tMSLRy`Vm2bLR1eMpXHfN`h)ED)zLnSLJ9ZXRUQl=`D^{jjPHl!X^Q^4+yoSH%S8 zsAr0mUHg$#!h~kc_R*RL2=Q|cLLIUojlv*ng&K*v&aoI8@N3fhAE)q+D$R29gT%pAi-|iE@J6hX*!Uqu@qg}Bfg9K5;t1DK_A0J*K$B%C|-GNB{0D7@WS%Au;f$Uc#Y?0^*)A+4|ru0Y6! z=|!`HGoopkeTOr-Q$?VF9=Tx9VZc}k1v zf~a=$_8mVth!=Zy_h)rqnVN0Q*nvN%q9}EC=stP##n5LFv2C$&EEHSxGklJDkX5XG z3$aIjffwLBKnp01pQqXp?nmagdaxK#Zdbs*!X2b06K-r*!`t)Loa@2&7y8&T?L#FK zk?|L458)L0GG@X$o`0!!wz!7qCTt~KK0eV?liSDqZwB5 ztX>k%w1@a4VLApQ|Mbs#-~=V$gsTgj&;lmRT+d7=9X*x#InBsYbxRu)vy<>+6GX2e zyJHi!AQVCh3;du18Fwai)l2?#HQD^CE%rH!vSs0j*wq&lPd*Nc=g5!-!xb&v`2IK_ zBW73RBNWUlQ(W3~gOdt3549{o6%Y}xaDG%(g1j(Yjy6kAGgKjQ5@NRjPcc)C-npU< zx7s^Y{+~H0%>IC1;w}xR`U8W*3ZeE!U3El3BG!XP=*KNG#SP8cZ-K`74RZ9~X!rP&8tir&0Bh z@Rk26mFakBo}((e5xXytB_RI% z+Iah%ntn=6zm)GProhzT5CsHY`(&<(6g|j}53$)%OGHE+9O3?*ew#osv5#4#$^ciu zJH+cw73BNsWCes!b_MKRqAwB0SLfpu6H1?w7BTds4}J86z#c~o{kuasi~eyPB>1` z|K->ta^0pkMbrmNk}`4)7_PVUQ3;!t)@?A+{H)YlLhc}BZT73}dbi|ZgY}BxSN6FN zwI&hNTE13DEw-G|5f7Qhr^gvvd-C;o7w9f_JwB1U6c%1|CZK7t@?p?|+a~ck%K)se zZkESM@2~QY7pEgOm`l%8+a}h16R-DB&cOT1df37miLZO^o*SYO zsC{q|v5MvTLt6W;RAO9=onyNsbNM&VCh#oJr$ypL-SYbUH0|4FnS*GkzY+2VG5GgvlzDmtjwMBhQi?tT{qTA!iUy z%YknFEATn9)Ic0}fqR2_id{!(YX^%HP%Az}#>*Bz(1O|}-)K^q9i`9Z4*Y~zuG

$376?V0`?08GMQL3hP_9ZU_dVD0C`C0g|-K5AwGBbYMQhN!df*3p|;|QUs zjL4S$kZrUlw-@dK95bAQw0_4KnXSTvM|W8lERDezo^Vv0Nk9oJ2x1c%0J4ca8CKoB z)B|#xu$g1DdimR{%`C7ck0 zvSyipYn(`z!I50JO^1fRh~A<)aLf$e=_FDth}KRk%=;%qgEv#Q7?TxpWUl*}f-;`0 zs%V%$euNr8P~)lOU0f64n?O~)wb|5Ot%q146}mvBF%Xmns81YCNy~m!Y+V>S3*6bN zBDYmBe7wkyTH#13hWd22rvV`TFQ;Z2#$oQW&8SjK7Jb;B;L3;T5?0+D@ol&F)%Aa zXc9o5Xx=)wZ;9MS+*mCNSxdAo$p2yAPi&i?v(6|J2U4)0l`&{0Ov0)MmtsK0nsmJ$ zULtLtFVG|5=2m7HM<9M|)f|#yZpf#OiPa8Kl2Nj%lR`xPC0^X_HrSOyk54PS*g`C3 zVFFgH&T|pDu+PBLL^Uyk87vBjSXpnwbV8oT1EKqMC zRp11B>ON+UXGl7UI9pDwLQ=ARneAnh=1J2ijz*Uj%eTn)0 zMYk7|IiurxkE)Cw$?4s{WL>N=Xjg13V=bJYw58lw(r2VU#H;ntAA>y6g!-&?3@($8 zDQ~#&KCWOv8eoxv&nz{ctCRJc`o<3}QURb8hQA7YHHNz#Burm~Ur>mX9N_!bk~9fT zXvs*i61R-SPY3d3n-8aHsabL;LWS14n}^i41uW)sFe`J1h?pgpOY>tri@fQ94;J|V zkWt?=e2iI;2_qHXE$o~bp2VL7s*oB~ICkH$T|dGhck#Uwk|lSk4n72JB;I(M{8lpG zHr;2KUtDFucJLHB@$+W^m5LS!k(_Gc0SJc_DOh@K^S6NFJ7f<0+nqzV)7oLm=;`~_u=Q?RQDNXiCuWGoc!hFs`_ zw3|~kT^i-y>S}y_!7ym*CGazV3?|Qc4mt{W=B`|*XB0IN?u~R$w7q%z6P74a>;Q5| zI<3dFJGxo%_Tq8UX9oOFQJWnbNe%UH=Ycz~N50|@5iSvK6YBPn7VAI@I0O9glHJ}J zw}bel7j2mFiaH~RT_)Zht*0&sN9g&yQCeVp^nc)N20_?7>|y5`b<0|6LCB_cffeJJ zhaZJD^wlj{G?PT|tgbuLbaklpm)B$i;cVZ6Hh0Pv(qCdDR$v~23vNY4~Ij?p@8p=J9*_T7PX{7i zhgTCMWf44U9PYkfd~zPS7F;8_u${k2f$(+Z0{XZ>?8~*IdyVmqouS(th zV%+U0m#@?=Qh;FNq}L4QGmo_j(_6FTm_leh_CjDyi_~w9 zQ6GaeG?qGB%;Evm5F=$G>CV$C8MtWhdrTkh2P)<%gDdReo{h+O@^cMA5cMijjNrbA zaY?(m$|`6yRTTemT0{Np!8w!r@TyyQ7m=?;^Cx$tt>BH%iqq4ij(toc?rp zcpoeCYoPLRVLf-th3u+B78P7GWb32dgi|zi8%Mu-9>Y6fi?9cD!irdusy>B0(7ZgbVq8*nZcBhJ8 zqBD-ZTa;}}{L=k`jT9y! z_JD(ihNyE7%R(2-@em&wY2uC6vI?_r+}WAoZ<8}@$bw*~PNbIltsIpprE)75$xQfj z?rf7Xb8Yj0_pI6sM&X>B??1;(l8V<5oo23SL%2L`Qu(BBk5q47 zx8YKj++hnLrUhOaQY(46S^FLLg1B+3n39m>4TO3Y)0(L`A`ZI1olrieo`mC)9epJW zx_$TquGSB9VU)-hvlQEw-?V$KJ>8!y3a!^Q@1Z3|r(YVO<389v7jc zxH!P@xV!G7iOfm~R5=Uhz;4jf`^YtYH*3zfq2w*L9@NU1ulPK|OSJ4}&E3Bi&N1QT zpi~i01=O&MA~<@hvo82oJ@p6fqO6t{Y(cXF`t@yu0aR#Hqw7fdI`M`@Th2whY9;JPr69x=}O4E1O+BRM1U7X#*OK_p!wwSZ~U*XTRJeN-DX9*J{?~xNoH2fANfKi7!1%P;Dz@uB2d}c+ZxQBh5!~(p?=Z*2 zb$$R6du`C%y7NB34hs4g;*7Eak`8071Qx_j!gy-hsTn?DJ2tg_?kiMcjgB8^30ygr#QY zk6+NBO|HEnm&y>e=Xmcf0WaJ`e8kF=SZ`4kzFlH#zXCIf=)kw~WS2)w1SQMTk55=m zuwYR9d9hpvl?PODDL(AO)@wEkn)RXWie}fe6UH9IjDKuwY&e^dr?F#XV6kRE9C8FO z`b6F~F1fcqk~#a3U`x1km3QdZp-3GVU=H<&w<$hD&A|0u=*ZTgMhcYfREfhkE} z+Ma>>jU5GYEfAxA@tdrZd>r!|_}43%*=zr&Ue;D*Iyt-Bg($pLhcB#e<)Arw;o5E! zYIsBk`d2dpNO3PBW7~KR%KDa%o&Al;-x%z$l+P+w+9eUHb*ky*53RiWDk8_2$`BZ} zi=wfvTKVh7^BvPFY!YL1F(u+?%!Te?p^bIkb|6w0dC0@SR6MeIT4pW()Svx{K9z=aT8EviQ&ruphAn^|Mp zQ7-W@m?a>d>a}TN;1b74&o*e-?a|%Ld1SE{v1>G_fy~dQS79`JZQxo?vgu|MOEZ^^ z6+Fy0FX56rrHvv$}1uqK+B|M*@FP8K1o!YctMm=q@C@(yuys_p)Biiu0HySePB zgUToolYAij>zS{?Fp+7npuhXjhfm|C4dxe3DsC|M`EW_@bn}7&F?8yyMCL1WOw|ON zB6v4)d%SU2zL5}?EX4wfgU|( z0|e1m!d}n>!Dy zG@OW!d=j>krhYWjdqBY; z>x=0|W+E{YIj@;+)-mk@&L#M!2&9f~@T*91zN^gV>fpPkT?C3*>k1|G$N77k^N<1I zKgN*Z4d#|+zJry_H-^F0eRn&LbJWL<@$8dMt`0v+h^SEca94Qu0?@!O@ff1CcE63m zrKZ8XW4$gfB)(}nL~O6cZzAkLw?6httYkhXfWHR!oX5@V!Jbj9_pAc}ACEMXso?V; z4aYysCuN{zi3Q)x4m8%~azq1vz~SqD;rSe>cnD|N>WhsWNmV45h&DY$(md$@E7kxP5r%x;bs zc&%mWa)Kj#pW(m_0s*x%QrelDsoqXqEGx_z!+i{jTsjb)cAZ)p7^u0T(=)fq1{5-% z_D3=}{+uU5JpVl|BlAp9mRYBtc0oLgvM1pJp`cwz8-tHV6ySX3cObsa&5CM^a#yPV z*<3X;DZq3h%}f%#uLPf+XhQ!eeEu@9koiN&NMW@?-wVp&8$IkwnUZ6{&(3N*I5K&^ zlPkJ$1kFExy9~Qws!gV-^tt|={y1!@?^F`OHUDH2J(mwF5)=;1msoI}KHG$76Ywdl z>1JcyY2_j<^zXbYj1Q%lxRvn}z7BLMnrN|zUkkbE{c6$5b@|>@ zxDMoV4gNGC77gerZvj17U1EF^fi^d9%{%wmtF@KtTe;Y4)A9TF8|bnQqWeUR7PO}$ z-dw6F%@_K?=zPgJ+Kdk#zd(edHk;{9mGv0a*jKxg3SR}XuVqOV&&uKv>}+ol~&Nl&22sG zavrLBnc<~H z9!?PVarJ!97w(NaKc8ulYeXaIMz7>mMqT^Zq1`MU5m6z+c~@A6nbxuIxXMg|jJw_K za;~U)p$mPRsP)fTipg|;h`mX&S%bMxT6NMqL4tWe2Dk>(w@*>kV9D|IjN#ZHg8o@J z?_Yo+R`KW0F$BU?xbCEN@|Zy+Xs0b$^->_^6jPz{X;P-$R$2RvYUjV(gQLI&y=IUf zCxN>um?3h&+}A`4@P-Jv1mD5sbI!Bag!hQG>RdmHF#StZ&o9;_0MD+CViZywIJl)N zkGp^0Cg6{Q&inh6aGhDx^Rc_)`A3O$l3(=QifSqrl;GKP1v~G06=x~AC+qFW+WAY> z@OmB>>wvGzEbTzrEqM13hXOj?#`*Nneq#(1*iigkDLv)^le>xEE%OR~Y|kkuYPMZ) zOgIY|#_nyYIRs_%0PJ__e9$Z|0{Kf%A&bk=Uy*B;65I6iLRI*f_RX9-2BK4#{=x-( z6HjRy-N+#(u) zU-O~g1OwSpi5_}7k;O6VsU;gw`vNVrXeyTp-n34V>C;*>eehi_j~u<}yX5cVO>fh= zhdaBaSaJ-y349)X>SA)Zi*@Q(6A>opB6z@U=?htvZ5SR778Egs9lV&Tn+t`X!3#Dj@zJ z@Of3bWx>Z{4D0#9RAqyRpb> zPh9mZ{uY77am;59XraAmwrF3__MvfX1VIAgxC2PqksILNoTvhosej(_3SUERtIy1) zT@x-$&2jYeG z;{hHg25mh*#1W%*r)&P+-;NvLu1*75L7t>aHFL)FpqweMhRs-33KCpgG?c^Ur1gcZ zhiwXu%!fdH2etrr*)x0D^*1@diB1QrmMHRZAkeoFVZht z>P~E3kIOdAAf*R*)5*B(HTmZbm82ix+n6|P6Fn#pdFjxst^>;jMkB> zE*x(5w!vI`zTsHmum^Ftc~ASbp1rH@=}2$77fQziO&7!JJUh#s5h72YN*q ze;ujzCgQh6fF<7%9j?{)g1I-Y=j2n;u&P*z0M?>Mg$KNWYP4 zax(p1DS=1lpFfgtl24$3UQQ=&rFD`ovRXYDAtw4^JLj?6NIf&he{0_|t^QpL@fOzC zkIa1u4H=8O%02Gt`EU=O10tZf=pV+KkeoW91n|i@*NoxdEsu_pg2$sK=>;uC=`Krm z&9EgU717A;me$R&Z4aeehq2+Q)pd3Xe<-1w>lRxpOMdUHNw>+#_8>poy=YO!|Lnpb zKEm9O?X)8^xe z3~%4fT&;7d9UtH}7i{AgZm3@IX;?;i0?idlHSIR@vQqD3Y5Si-;I-aPq}mIW%rYU9 zx%H1EnDDh-pxY*lV0tm5AuP-WJ|Ft1l7sCYo=h%<-E50cB7I7;_!Idx<6<5 zNZ3ymavF+rUR;m3X+(O6{9`gj#b zzds;?m#Y3^7$bYe<%%7P_oTc)bi?{C9OroS)W}w`9F8=C)1({b2q+Z z|CkBC!1D&A56EZ$z7duI13?_jM`#}^wv;*N@V;DPTxZU0I$TKxq(8`L2V){7WNe$) zb`3e}5ti8Lif2F{hZH@DTX#`-a!u9GU(H5Ur82?p z{bo0~?i;dfR!n*;sXtuT7O6FAj?l zARB0=)bbQitgBu>Si|BiI*$aqkjTr*oJwu}Z&q!Z3;mc9!fHZ$myG(FWnBMB^uuuu z+X&>rv8y-hRp&#$+98_zN*_HDX>r!r_R0Q$M8!VpvnMqpM=W$D>J&4-X(5>+ z7{AHQN_U6xGNiNZGARz}W27t4o%!So!ABpF+-*{hnY|r5SQTL$GljOd%BcMmf!v zDe>1Zz@Mw%XWJU`dlid?K`N!hBAj7;ZAek8`=c(mHq3e6F&2-2^+^v0G+l<<<|7K^ z?PGgbl7`|v!L)qW8+FN-xl%k|;olm}=p$nU_szq_<1#H$B|#-YtQUtT)IiO2i~H6@ z4|}bcvpeB2Ut0_P5UfumWbDi;vZqgHFB@$o52==Ifn`10H6=M5!%+VFE?W6ytnV;VXzDb1&F$J0kwzhNsDFU zvAQO_CakXqsg|%dvt8)q98WylfsmCwcfF-3+=h*-gAoBrZr-|9qOgMoD$+fz`m;I~ zp})Boi_BG#>2E%s@9j25>}SU^qCSccZ{6b1o`%(M<0Sj9lF#PyWXn-5USf`Jb+W7s zrG5||qDD8=VhtDIf4|+>tuj){d;dsf?%wc4+&-E9-N*gJ{u|ZITdaep zxMnJRq2Ny=xj&E#{!}9@5j|^zU>s04Md~uN7bBQa1nVjx3QrIb_!5e=6Z3azEQ_40 z5|0elQ%VN^LDtpqLvR5d6NC0wSFl45K@}@26Sxkx3f-87gFKjfhamXdvak%ZK3hEn z77RC~vUC*08B`U9JSgT9-l)g4{*m=Xqx3qYMcrqJdTc&{(&!l;YsWg0b>@%;*_IHP zJABi{8p-Rk+h6Ta#T|YJnYt|g#_I6#GA%7g(QgR@zqB6DJ-fw|>snqC<&dsT;3eC9 zrOCagSrw_NfPkhzL#AaN?7n$2)nE>PM-rk|fB3v3Kt`tjwZXWC@c{8-INK29Lu(Ef zr^0sSM*YqY5TBzv<4y)+#7r?p`6{;PfxzLXb)Q*uKJn#Mm`fnv4Tv{s-wb41ydjDS zoh~9N+8B>$HU^}mGuj#lg)e^NYv;3figa3K&qXeqL!2rg_~~~*?mx?5CY@V+&;Ekt z2=)dW=D2v~d&XL}KW4o;?lOY`eivdOeroh??fGhzrBC_^+hw{ed_vacju%GN+DOBA zzdP16zn#zF$zvoEa^#|J+pmYXhJcRB&v#ib4-x<)=oE@@4Fo0Ijrv| z^26sXR4PaLaMUh#7{=4YEJR>sdc_GpzK}HLWl~ibcmI{6BD%UZT9<-&t!NtcY#}rl zh4>eU9o64&Cr`{rKZfyq&UV`6K(_p|o;|KsfVJ4lH1Ir*z^vZH420yGvqo=fDcBcwWHbUM(QQEZ)<2<^Fz z%sE_q@5~pn#*w*AV57K&ZX?BI&gmWH^;CBi&mX86bq{1Mm=lTwCbr5{5V)~Xj={v) zB5`$g4mPiu|9`!q4&ZXSvQVV7XLz+Mi%U5tmV%TkM{8HXt7R zgl`%y5b!>&D`A}XB#?LG91Q?)=uWJcxzcWDE12SMThC4}2`JrR$ew*`xaLAD7P^gT%fUSu@eVI7+mZ(;d*vaHvRRF< z6Ts+Aq{EQiP*3O(Tf!FT+Kt^HV+hpgjr4hS3cC3Zv7wkyq_%ifw^)_(r`4ylZvCoY zZF(>Qq3U*7SE8vmb8^0|&oWx?y+doKOwV!y2~jva0_vq(On|u`=|-=d8=)mU7xozp zaHT7#CN#7L8=80Hy$R~P6`ZV@1K`Dv;`gNI4k(>fnKZ3A<&$+BUKcRFb`pqewsQf{ z9tP~bm-9_iPguvfbDNOWH0@Sl`2@m{^g9gqjVq;GekgP*{$S1?)&?jlVxcvMicg7o z4H0=~g+qcDAdA~JQZVoB%gJ5EDrX4bs<<^qL2xds{#+#pIhntj z_{C4Tj2+OdB1VcGk%Opk-@h#W! z(|u+(x-A6qaE9K&C<9C{6~d5>w^PJTpzJ|An2y*A>%-nmT{*IT)^Zbdgq`e*gMGFG z5H?W)@-*NaTu;|uOGYT|YS!Dgv*Th#5~G)E75p=HUMKlrwztOlUt5h>TfQX|0fs<; zoZq&fIp|-OYH`bO8shgAC>O zLGsoG(Z5IURtU4(fzBF%9k-G1q5mBq&CO8~uJApB!7P_8D2i1SXrVBS*F3$S6g*2# zqW8GN!;!zBS3%mM57?vZs?M^jXM>IS0$86b%pD(W!Z(pLYq*IVqVdFt_%cl-2sHFN zL)S{noO8Rd?JptS*_I;k_M%jtkm0%{D2;D1M-4W7`)81qEv6>|pdX{D&nKAqypb?Z>^mSyX%z{5}Yc-a$+)zu!6)>WOm|FB-4ednJ77ZW+mvpe^8pL`Hg*kBv*u;_JIQ zLgY{}WtjmJcyh}tKnAqtn#E(`q8*GTA$s5lXHOXvb?Sj}!4^j6ebg;`)Z zM&r-I+?N}w0rx*r>@7o%l!?`7NW7lLMZyqYn-Jbeuh=UDr!PRhN>m9Pe&x|7!CGqT z1c?Hv9+4ifMwerHyNIWo++1`g_Ad{sPwPa5Vb|8c=faz^pG!}-SRaD>1o-Qv$9LlM zgX|dF!5?ysN?ZkYD_A$T+ilPASma3RG9yIBZdH2ok!qNCRH0jP(}FG~fQwfb1XsqJ z)=~A4O=V`Flb})s1hXlV!Emn1+enQy%L!mbV{yneQ_>rC?3^eu*@8GIdk(>A?F_w& z^FlXebxx=ku`Ur<474AGzvfALqtEpGdIUtaMSpp&a=f%2BlOIiZ)moAMm*T8xKYMt z9c)6P4n{J8_RQrhJ2?G-_HTQ8Eo*+WTim&f?jWhl90`GL;rkDKIC8{FS74`m z`Fp*YbO+c6BvT)pUIS5=%qR5Jc?QPXvR?O3I3i!Rg~~`jg79qmZ8^&|`g0Niib_D! zwUP16{&Q^ECIJUkIaK^{Q<~q%hZ!vXkOPOeMiEHuZOiq1Kt}M2ii*H)eb2trf}iOY zHW`pckQTJyTX`}{k~KaU>}gVq{|3$x&wKtQ zZ9ZL+z&WJUI#|pay6084t%)KYuO$jPBw{%7c~)wo+DwbBb9A-ZJ{gz#TetEI!JZwL z-?Lzw=?z;LEX3gCs=i$_PJ0Aq`Qx|&+Gd&T%N$i-KeFp9f$2?3jMEIXzj6uZ%#1<* z0e(QH#k@rAF)?4Izl zR^tp0w)Uw0QBuE~x;X~hJ;T_?%=kD2OvSXp;TxahCao7U zY`vOsa#UnGQCq&8vjgBHZ&=?y`X(k4Gt)e<>o@W;JslyuIG1uqfNxC2cJ}xd+!2a= zuW!7k4>l9%Btz0JSjK74OKxWO6bRm6214ObF=tXbYNU52BcX(?y@XU+@6 z)EMo^<3Qi&&hKd#lm8tczhUd`t_TECw|Kvx)xo~6Ga4dEf4#z__VGSj)#bJ<5R7=U z27g)K^mO0Z8D9xL)v+0)%fd43IHdlJSM&TS`l`7Mw5bAV5lB5Hhn*#5Gn3zka){)^ z#nONOkCf&RAGx3_aq{WP&{f-}tD6IsTPO@!51CM`O*&m5f4u$s-V5yDStktv4Av_f-l_E+l#bF0rP_8avA)E_~wMWa44~y}Sk!k^yU#sYcXZXH|acy8gwqSuJ{qZ(8~967 zZ^yK|>pZyh4iz6L0d4NA~E`cWSeJ z0pt?gLs!BYvpVAC%$1c1XRgYu!6KK6)m|P#ZR@`gyKjlIfk?tG!HH5cjv&hw{Tfq& zHjd}Md8pW_#8!}l?TP}5#+(dP)*dbkp4N{K`I(ec&O98IPspF{D#`rzP}C+q8$0x%&%MUBTQ; zIcOv%;lzJamtaz^BPaos{0`M3nklaDW@q&W*!$|}ud>yNw_Cq`|0rBNm>T)*?xo1; zFY8WyOIvrUy6Z{d`RSd4nS8XehhyKq*u4Szeg5T@^%HMmcVF@s)A;>ZY3woVQgQkF zQ2Tz@pz)YNv@K0$ayVJb^mgCPQ3%h*86&r(B9vk7y0I&D!cC|ANq&OwfMuj6uDUF| z`HttbJ9QK7W8Dv*fJdvHbvp6g^{KYTPRnl(&6E@j(Plf2qs^6*D~29_&+|QWhWPcV z%GGpz@tW+UuulJv)4Ya=6a7$GJz-T|Sv&Epys|;rIVWkT=2xm`$+z#X{ZI5Av-z&1 zS{}3KrGw}PL9Um2VaJFQrM1;h?Bkx?6j*m|pWU&~vw3tC-%F}6EA6Dbt%Os<;`hTuUa<|h<%FDD*ObT_~xsoQ?z4OqnbDwV#E;anFxY}Oh z+2P$#Gd>&7NjNFJp*Be)JMFlos%K)kxS2?0TcgB1@%}TGkN^Fh#Q*Id%l5y^IaC~7ZC%$ z-!OVMs*0jRRf5u$;^QR`-hYl#TI0IzcaG42<=wpApz(jhUyJ~S?A-*8A~^q<(oF~& zKwEwu-+Jwk9OsT5aUU&J(vQZ}WQkOEcuH;$y%@YEXQ!dT+6p3J#l0NL-d;T9RA8xi zJHhW+`-H~Kg|WdrL=9&j09W*XqZjoisMbwi1WWLTN{xQ7 zt0riV*qo_T+WhKF-i}}I5ULY?X4g*?KaghRHC|`khjPDbzvA9((`lAkuT4xgjEgSp z|D|w=qw2_9?H+uEAQFq#=mcy#7(RWAZ(A(hKt_h*VaKrnGL3rui=_Xm4UbnR8|pi6 zj}j@NzbWd~S+jGZVCBW>NP^wDv@`@L2Q#4B@K`~AkNvTu*9?N6AG{@oe&m#PF}=iU zW{{S&TTvBEfpB7Nq-o{4mQO+qRu0{eBz8Tzch}GN4Coq*s&%Gi-bq3X1ipY@9sY(`mvexiZZ(5MYkD}2{V*?j7QXklsNH_M{j&s|;40AeibLyf zSjgoQl2`6cQ_4r~{)1hu^F1Jdzjx6Uy9%m4!e-y86UL<&@nKiRior|q|g>J656e*Lq?(ic-FV{m$!O&=f{Kr#u& zqQ9XYeLe~9=yhim9la%S1u1hwhG&+nt5l&bw~-;+Y&TsAp59d+clcd!q^(Dlm)c9O zVB9}4wtlM}ZW zjTCkKc^RS7K~#;L-x^f~T=N5WAx01B&rUg#j=+!TPMGUDnU8FB{aKdh(ZlEpe8qFy z9)wo6+!*-wLwS4kV?p$(=FJw*56w)DO&WAuMm|^4059B;3&sRZUcMttl{ZwIHWmOs zz#i_Y+a|Jga#k+NAgX20g}yscz^_+PG{Bg+KWxB@6Mc?mOwey;IbZ55RTksOHq9r! zOlY6J?{jtCmTZVcgIOB4qWinLtKU!PJsF|o-wvw(G=0VlY#ccU*4Q??hSF6o-owRi zhvKh@{6DI`Gpebr>pBp`tAbtx=>meHs5I#ykb4nP5J8k)rAQ}K0jbez0i-CsBVB2N z^ct1kdxs!Z2)#oBdG|@c=ljO+r-O6O-fOQl=Uj8`c*V(*bm9r+Nd1_YNZtn^TV&|{ z^_ATBhpOh&Mp^{F|`9?9dGv_gfK;50D4;rk}n zea{}+7^_6YcpmqPu2b-j4?k!~+czT`!UD6?t-fZfgGng1UG9Y$oq*{gh-J7J93xn3 zo3Hx?-!q0zC_h4V>~3U^4T)W3mY~vT9G)y?DUo_Js5i>MuGom{XZ+fDR5w1dbi!1F zOQfpmu(yeAwZ%5{Mcw{%=v2y|nN2@6)sXsFMY?Y{cX~={Lu59KL@_PyL8RV?HKoKg7bX+@{2k8i*aXAyfS(CbOgQ*E-Js zKPaB6sHT^E*2R;mT-;;C%I=()qX#NXZvF{hZkUn}@w0k6(f!I7Eq@Ld zC-c}(zl&w|#v4EU5l3-!K*9#mQ8*29@yttEvNB<$+HY0y^mxQFt~Lt zxv`dbhG6h23t(z1{TgwB`y|PxL>-MbqsgygV0Y%e@ZdTLT?wEm`O1GM3)?o=_Ey?F zUC^uI9CN`*BE7B1NgO{Z9eU2UXAhMbs%J&&E#SBB3hg9!k{*Icqb^jaeM+r;V*L{- zWoke@5*BL7>(ajkzMH&+yy2OjP)~opNn7#S8L2ZRG)G*S58}1H;AN$=In=}Q$-5)= z2Wg9J^aeDtse~tIf6e#hc^BnBrEYjL>(z=Qy=W(rQih#I&Pv_P@hdU2bgB{qVQXTg zl&5KH=2UA874w3tLx1`^y}O+erqL#Wq^;F_C=t zpyEMTpB3A}xz$#_azA+#5}6+$H*P4)m-EvUpfRkz82) zO}#7QnLImkuOG{6I5)EQ%oioiG+10FGkSA&#-Jw~VAw@@1CJv)2%mmdj- z3^NY0(L~<4<4x0Zr1BzaE(`2d*UZq;%|m}@~8?s!;HV5r2N~NFn)H+W1Jc4$b7Np5F^+QeU;47|8}b_QA#M@x%)n~po}2@ zQv(v@BBQ)+H=#fyct>54#4|bvz|cn?*~GBF>=R|vYsDn({p(5itCQy*uHXlJlxgdO z?{Cbu-n(I^8z-7=s|10xvQvm(8Rf6p=laVa_PJooA+~Qj*8Ax84ufc$^3_Dog8Xi_ zdc|2qx=*C!Wt0?24dvaz0eNlyrt8kNDFQ^B7S5}rrl6|?7=e!PiRnHvM-%kY zj@ZfR$?wb5K4;) zE0~2rIY%I?!(-ce$yr1z>WEV!;EaQGG#S0$gl95|GEx#ht++I&9n$w?{C!G!5mE|Z z?nPhFtd#YdZ{|-Q{Y${Qz8AvB5A6Bq3WfQv7oV}vExSEa?^|h$2EZbq>OUtZgdJ65 z7RB`BzlY5p)GO7JzUt(3P0dd(Z)oo<7GYz?N46?)HJC&K?~e2wA4A#Z8o0CE0|KDfj9~P{@oLf0Oggwi!+14$*6ZdT}L${rUit_><=oMw9im z)Wns_V-jD;OzGAJJPRD3viZFZDXI94a~&#Nc5DfE$8K>3vWrP7M+?g+CyJJ7@o*qn zzFK@AHRky>%JJ1|rl2r=shC9i%AC%iq;pt9&it)$R<&~>@MXkGmym)~z$Vroo1f)= zHE;1z_G*6FH;aWPUEk@KPY;^|W$Fe15o!R>bPB+@FgCTOX&;Br~JmG zu-#&&0gF_WwZOn1^062SW#lth^%%!OQVobaRVmDQIC-g3<=oRy4u1<$qP+bdD)L}T zHr?811MA$g%Hp5y3&rygNKEN_-~5;0>;7apyh@Kfuq`cIdk6s z;VmO0BdY1UaeBfXCkVO!`tYF{PT*aZ^=v(k|q~S6F#N592kvTrY7QF0E-w`Zr8dsu=-2T9o41ZaQHW_ z$%*QllzZbOj2#TgkXO7`lWu7n6&wMs7#4B->d&5;$Qr3C!oi9X;c$ih9%gS_-EwrW z%hJ){iqS%fE+F>F_}|x1`ApKF z43b(d-Q3KO=9Qje>bTe8IV;r7WPV#n8mDcZO2Bszt`*ZX;;2HStQb_6qhy`!3RBKTG9{CaSLw9ez?e&6QYddmzu4LP_>N93-;D@(& zOLbL|L{W~O0Uh4)sVqaglb|#b^Wn@&nYgTZf1$9`eps@D`NH%m`KJPgS?(jj`6hQS zc)XsoMcfUlEGvybR?pW9&AFWW8Y88gshzK*>E8w z$`bGlX?U;l>N(|~$eV`=l?RQDG-7GGcgOW?gEc28oe;lcXc ze96FJxyAY4R%fGRY#kjIoyZLXKL`q*ktG&0qXKonsjcwU^|HM<00+Z@VExH6!%ECUgrV%N_<}-3Ru1WI? z?u;>EgGyv|dml#uukjFbvR6{_u;ohd}0p8vyM2O%C6Xe z=VeBkHe6qgEg!A6zV4`0aN~PPf8zu|!|UtyWk!*hklcT}>IHiOm@6Bh^5tqL~eOP%yfe(c2bRK?^q5Hy$kKqdfZaj8qpQeVmU}^J^Z;jE@7pUvskC%N! zwBMME(7YNgXzLgwWc{M+T8QTT#z4Bd(^vF&;%S=lJqb^nY{K$d)CxqpMV<Sp?Qkle=&E>Zva2 zNN|WzXo;Y!=Dz3dpmtp=fEeLSuls5`T(Ej=E3Zs}QSMLs0&Z^fFObrM9m7+I_Bzz~ zJ>Wu*8j7Vgt&Y5VOpYyt;lW;uCOzl8R}VIO5)B?NswV_6m0gQuAzQDWb65Api!LMR zbnx;1K4T&wnC3Ph!-e@6e3aoIODw5}q~`s$JfCRq&7WC4)qV%Od{5;^_MxBr4t0A&y_;Jt|9S((l;9Kw*WS4obrd)TxSU1`-Q zR(b8T$ zB2wTdZ}=*1R&C`V?++NdF10@c_`qMfV}qr9k9Pqt>SFGp)I}G9X>Y6R*ZXT8YaFnW z2irx!%XqHvGhnvTee5(WEywrLeGJ+-#R{fN+BRb6+?jAYAzC!vSWz4|0~lMGlOQH8Cz z?|utk%iK&tUk<#_O!{xz#uFq~0pK*mLLns=6%0gD*dpc{1YS9!kz%9@sO; z-MEX_zM7clxgs$W_PG0VhRi1mszzm2Agl9j>m&jildpc?Zo9E=fZBS*pO@X{aWYMk z>}i6&kAfx~9aB8g9jD#X?ON>>j(gy@EEMRrvJu@XO(HFwCXnvqpFsB?zJ!Ev4k+H- zsSRfb}6-V?U0* zGZpz*U9~+rau|cugd+?-aO&chU90u?upRh!do)K_>>3Is#Wne%kKa(@Ut-&C5JQo0 zo~Gu;cX-=nxjO&rtxWfbx=`46^jZUZB45??BuIYMX`t zU{>D@WVG;knCx%mdFNYaeboC;e^6LVgkL{Iqxj~1_tziW`))kXRV#2%?qW3x_dTef zuW%acpXpcz+z822+_ZPNQ;lVNlH#GtRBt7dlE_1>$W}HJF~Gj+b4Kn^iuhrfc90^u z$}G#FtQS>E(uN>YG}QfYp7gt#TB(oT8X$-UGI=S*NSG+H)9Cik^~ z4p;tL-hZr2l;h|znzhA+h&!gO8C+x*-wjn*Mu655! z4{hj{maRr$Cfe&t^jh2GX_3EPoN-lAK0PF_*(Dp>lyxr~s}&6tmYdsyhrjwTDl6_C zGr3K6dZ8`{(X&E{dx_3%!9G#x4-!J`;>B(NT>WSPEV#zU!NTe>>HA!WU>36HNLXY2 z-C*WupurU_t1PRK@)J-jO&FutXilymQH?%ViD;-#i9?poE(d!jda8f(`?19Ll!M_P z8Bn59L4S-Wyj(dmm(d@QCNJnp?2bjWvoRQMKS6AJ%-G%pcSbC7G8}9q43`Cq8d$ zej-d^v(TB8%9Etqk)}@#%hF68U5(I8a2vz&U9T(ZV(DbkD|BVx!Gun$TztxAsf4-qs3VzX>()~DLeR{I35o-5ApnizrtAldM zY2MSg8Q#SeY*|ZPAur27YF}1NPr(&s%lg75D5Ircm7c#ppP3Tm$h$qEv-wRq_0?I) z%u;sg{jP=!y7wL=2v=JOSXvr{t)E{mTQkMlDZ_9J{fSwJRhGy(H-JcAD?0_eDH*6* zc3DDQU}dGT z0hMKgBSlxx4;p=@g$!vi_Z|S9Aty5%d3BU9V6c_R@|b|jsH#|TcbD~9eZA1jrcfU+5I zfqPx?O8vPiDesVs+ec=fq_>p0nHr~Bx=6C6E9B`hod5N+_8g9z=hzU0Iw`o?LBKC; z))?;UL8`cwe!i2EFTOvAv-gnUQ*(^I-C?6L2UbeA$A7l2Ze*}*l@LwXbAAB;>b&HO z5Nu<|%_^Z&k`SExqFHrIe>digzd9NBd-lh|JiG?td8M)!+F;fr-H`UW#N+tABzqvX zV+^-5I9l7=2MZ}Fi@xok_u?nkaAkaEoTPSuuojMhGHT{BeE{B_lz1MJXDHOJJV@Va zH;rMPX*>#j52279hnm?2*$2uS<_z-1m7B$rPgWQ*n5BC_pNbBtGv_rp*BX-m(IBk_ z!o;g{qEGv^XrC2{vn#N8B}kLdnae(S=y}Gna3X1PF<-5WptGC$@Q#0 ztcZLr0-Nqrir|Y$gii7JDkU|{!($ugHTCaN`e#JIH5Kl7ll2~aFA_#l=BsTlIgPLCnINO{B-YBj`si`oi#XnX3AZgO&r;|JPU9~KW%fd!h1!;aThVn$!~ z?2J8;?Q(D$(9!s;aR@X1Nu2u~=%4I|tD8d|o|0`bf<+eOzxAq;Eyz`SnFo_K3} zKjds^AXM!-HGa+&JtpA@%;pI_Ta&&AXk1Eu)=Eklp}v zS>k-w9`%v04>pmooX~z0e}PtzT#zhd#;*Xm!IH*LQTe|M;NryKS+>4UJ4uKg?Y*iy z9J9Nx`PjQcbfqLdpmzLAj>bPr?K#-);x?(sgAOILIdyi3C25QoB?^Ao66()yVAIW} zwBJw`OP_yyH4Wq?HK6o|Y4Q&hde4b+?~R-3`dW?EuzfjY1bU~&U?@w!7IdU)z1pGz zkFqYKg&w>OxZTIE2r57mwn=QKSea;+xM#nJ2+ny*CX(JQtOHG}BE=iXB6Xct-_yDo z$1|5!v|+GzPQz_&rTLL*%bWvfW!8<)yGJkbgkDW$sM*CkH}hPLWwajk6P%mKJ7smR}{p2uC%|1;u)hA}e=Td1zx z-37?8XFGrHzwcPW?^GaeTN{D1mBTNpye|47>X*#mg63(S2wCa?ha1 zClTY{)^a#E>0`hqowq39H9VcH^#Y)!<-RSOdq=`d+QM1Ql69l9_7f1gNvG$5>&z zmn>}7y$gssAp`>Xp-%&4nPyp+<|JNhe@l!{AmHcHfY9aBJt$ZLwdU#C_}$fEH)6t8 zWct-7d@_AzpB0TRekgglyX=~)2-0k-wS{cnr}FnMtQihR6dZ0hagJtf*T1ilHHe7C z?$iP`bBC=EewZX8Xl(r7Q3xN_qaisBWF3*2gF>gSn>F_jRZlPys`LG=_bO++@|}J; zjmI&~yPM1IAltv?m*%_mQR}&m70GWXjWcr~U*+36nRj>gYVCX(cYAI#R+>IIY1;H- z&k|}0ny_s!?ns%i9Hnt+z(bFPMoztj)Wnp8I*UKEWQL}9A7mbJWrQ~M?E_1d8{56@ z9@mjWrQEh7-Ji*lOmUWTV#cm~e$rA0_>2L!jheNqH4jv`yCh!p-7x%6A9zvU=1eE( ztaj1$xES_OsRlmt&54!te^XT1*Yx`lmk4aLtLRC;U|@&sZBlAM{A31Ki6m&o7esOq z`(!BK+U94gq{dB%>{#4&Rvbv^7a_+C zMiURq@E`+2a$VDzdbl$nYnAmt_RZ5RW4z6~8wa`% zDx9$8h3-EWlK(CS6##-lp3K4)A=qqGo{%iV7NO(yisxKSICbyZW9g752TK#36b*f=qXlO znl@>-zoROJ-TyesH$zbkGTZrcxBpwyr~bCT8{adLvg)PvUl=@Uu-`04|v1e~BYqyNk~I;+|l}GWIz~pw=K@Tggvt zCwKETVD^}e#iQH0P>xJsKmp0?I^U83jgqKhDAq>^?C;-s3hluDM~YO*;Wbs;VwT&j zpP{Bq7$#^b;78!fkjLxd(bTTt0%73w#4RM`RVxE6F}oQO8d z=2$dF{?6M|xv2Pwk2UyXbSp*4SFspq38k{3kLiVGQ_R$`5#|bxVoNWNeB+c4%AdbgU75l1FhttJU21DvRzPWXU1MRz458De30!M%zZKSaKJo9P!q z7WG8f*OYj@SW+wH-Jnt)UYnT5bU*37yfDaIXrU4VmP*KhmXUZ!XCh-FLs@P)(fe3M zIkE<)58?jLW_!uTQq+MefnFWh^2Aw6Um%`zf4QL4GwsI=YrZ|ZIusa9T8PXd)CkvP z>WhdDqJa|S$h}(nZgBFKKK5phVoKloo1Qb|g8&m-Zhe_eHro5F;>AhLbgNdYTk9i= zjj?pITd6y;#@Q@Cx!$&g;esx?7Il)t3JPE|;P}e`z+{j->Ew3crx&hNS5(~pyh}~M zJM;dm|7I;9a2qCsn&O;1$L2id-5LE=$nuy#gRPY4aDN^n>Hcqi&ANVxm>pRrR=HF1 z&}k_qysmntY}Ms(Z%*t#mMNbpLII2T`a7=fVDH^m!sh*(#!9}Gb$2hW@SBOxj>1A> zdmZ5jZ-a|Ic$2Y{fyWjAU(`A?xR~ecHFJs945)#t8ceaS#w&XrR~y`cT>p4f?G|h% zhB~}8V#K{|^4YiJbqB5}!vY5=@&V@tk1B)!`0!;-_p4=b6_nDhnuH0Q<#3oEG5m8) z%!VtUx-{?nxJ6fwU1tJv@33xcfKwV7x-_SKO0#v_6KK8; zo4I*V>##ueUC0nM z=Js=oyR2EZX@*twx5RCz$=Rn#NV>+y4;)!8#Va=N%CXOHkO}{|4zCb{wML0bfGt_e zO{1)e*mfEH)xnfP3K3Ng_|Bc$mM?~Po&JNuwK)=f1}e0@MLw#rZh~t^3L{vyh6dRf zofw7-9yc-7z}bUNMYYoy*~azzlu|P|HXvAUPX;%EQ=uI)y}yQK{V3+JSW>) zI#?Qo@)oe)pU=n$;Co@~FgDXCw!VE$KPzR?J8&O!;F`B+1#6r$?tlLoRIOi<0(RDm z4zU|kc%nrD^qi__92{I)EP|jbW)OSlcQf8KDiucg9=oiG0|4y%6EztpO4@n0tJ{&< z*KM{8pIRfek>g*>#n}TG+La*t!CC1|)d8IoxgxT1;dADVPVEvIfJY9WH|p8n4%3_+ z91+3p;FhbSojUeh0S6uu5`mQlht0`M>PcvlF!t?K)N2|C(>+B~8R*Un)WQ{e)jO>l z6E7N1MxN54Ej&Wvs?2c0A(H(bWZZ$=?7+ItV=scHhwUv90L2fO$u-<-)(orvm`IC{ zQ@sMub8kCg+4}oK-GKwBZOMPdWA4^qp9jJYhE~6Nz!BpKzg7;H-FEJn-28(RnW5Q| zku1%-PIrhg15(_(Qen<@Y;IUV7ManOe=^O_(=gDzEUyu#PaxH#cNceR+;noRVp1UJ zs+vr}x3gVC=0|qw=p~+Xpuy}#HN-L?t(~XFJ(tXAf(5qzoC?U1_Xz9!NDchJlUm@< zaceulK&K3Izv2t@OiL`V(w{;it;01yR81O@mh`Q>WN@8D*1XalWbGqC-j!yaEK1QQG9QTs*+|jZN{Eg+D%=FH7-^+p+4!F0t za#Jz(WXL~%9sdVaeYm?1R!^%7*6AvrojZ9bcOX=wUI{=k#-w&3i01ent)xV%X?2(k z6R^A+exM~b=e!jsc*hTSLRK=~R{F8hN|25*RR{`SyjLk9q~?KjyaC(%`LW-_n!MDc zWf%hL#(rxUo!Q|7Bj4qd^@ryUHZTAa#{!2_vPFLru4j(hxAHuCJlD3ShR{X^I%q(^ zAWE+GSk|^j%V!d(k8_1SoP~4pKkqLdd)CAyf##_5>nS?rRA5c_?jHZIXK*fs(XiR} zgoI%YD;Q(uzxv)D;xz{~)!#(gqD7>o7aj%Q%2yxrFfF`a3+Em)9a-sM@C#!0z+~0E2$V|z zR0G7cE_~U}Q|${KU0)}#3>00+5_kZdXBVAXyK*c14PCwaztnfc%*8o9goc|6O}p@u z%;V*se|i-%l&B5$!M+yf3_4!Q)0!IoTwgSGTQc})Grlsw3A)(kxK|Kg>3j<*M1-Js zmDB$0xn_SXVoyfVt1QTS-<~~C&S8d8+MM_gKWlGr=mTWe0JFMa86CK9qCEbl20fgZ zackF1Am1dU{tg#7wE2!Hz36_|`;K612P^e@tk9+RA@(K+t{+jUlO`Vv^mb%2%;`u+ z#_O4p(w*D6=dgy=(D)K+nA`qk2?4kG%dFss>G3>-a2#qBCg4<0M-8jm?YDA0V+j&R zI%vqLZFHZg8>wZ6y>C#y7SV#X=C2eW>TotOq5ZkqKv;-e&H(Ubnlhj(<*w+cP;Ze= zu65s@V>JmsUwe(>?=6;`BTmzAK7wZMzKtL9*U5F(z9m0@bf`D|QNZ+LXAVaM@WRS< zOILDa6EXHn7S=rD9MxviZTI26Yo&fo>rCQvaX$B;_97U$phM+sNR4sl;qmaq?{drW>YiG$bHypSfn3V zZwZK?lHHEXzirfD4Ztvm1~m+>k==rOlV{KuN6#A-n_ICw3=Kq-@mYK8??-3H$%a;K z{}@OGDV2U;qu#}qr{?>(Zs5Nzco}&(I-r zw4{556=@tKiQ)XNvl#LRH@EVGBa{V$UlFtaxiydm!~kp9?_aTBqfv#-I#f#8$#6%fcaMc^tGa?lH;Q72;BG zN5Blgp>yyN&Pj(V)s1ytQkEq{5(cKeTl!9uMjpW26QN@~-gm$y;^ZX3ra|dn#e2j5 zrDmsfszvY#AehRjPBg0-fBf3>TvG0&1xvS@&a?8fQe@;$=Otek+oU1RH_m#MnCKdq zwS2r{jky9^KmyLEuecKkPM0nPEZr+e^@vDJysBa|zpxOugNt8@Tk*npw8N<^V2qzV zP<-Hvmo5AbJXj{T@;sK{^$LPz<#nbehOPNEzf#!!a&$%e+2#drbzZny|9140(Hm-w z9Z?u7%Tho#nNiOhmo?zo>0_Ojd~>WZXTCwzJZJE29^gHJptxg1&pFXU=o7ar7r)hh zbYfRwaPTKgJ966ndDRKEmeSikcnhDUvR@w0m);FjZHt^^WS5^Xrbh+p7Bws-99phcy!}&ANcKqq(kL%+74M&IXukI?E(fWXZt>C z_uYHOGa$hCA$Z~x=v=$RLDG#rw6($vgOG)vOb8?W+{f)9&_gvBD)(Wr=u>OM`Ybk|c zy)|S;HmN1s^ZgfHZT$u+_n8}X{es1?xeJ%Y_{*k0?q>Mx+bj;3<@8T%R8_#JDrnKd zF|cK8nD{XJMccQ=BN57AV9}?Q)iUVMMF?L^I4aJdAGd!xoKcVmJCRgC<7T5cpLyqr z%lSw3!&*mh2YoJkITIL7N!*&lA7&6pREAFmK^xFzLvBr)z91kCwD#Ly*Q`+Mfo$TpTrWzHAq2_Ws;m zq^9+v-5{IKfo5XFYM#LlOcaaLQ7AW3c@FBst{V=}!N3Oa+?i0h>^3X&ovYrs^{6@K zE(V=8Y8clm{Vq>?L!05Re7W5)jfgGMzvYokl$6^Xy@dUt9-}o zaBuM}OgrTcQd4(vsEF%u8+rJpP-fk`$_S^|bIg?EsachI%z}%y6`+{AGeJcoE}Pb? zJJ&RvtZ)41LbXN|Sb#15>Od-I`5u&XzQfPq(uFUY5`2#KdRjN`+qH+Iz^ufV!Ab}H zrCjz`UKjld=R8jOe$as<3|)1XmQQ7A=DgFaYQ8O@DN~hKcKml8Z6*@Jx^e9w$D7go zO-7UHNkN|)HXfnY%&pWUF97XC9SHaX7TBAUl2b-g3By-kiNLf{v@*gm=ox13{5x=dFpD&b*otS9du&0j&9}bsZ%J)IaW$S-yj#sg$`!e^M>-GEU$v1 ze;X1+w2NqCZ)lFvoZMas0ud1fa>~`KZ^o!6s9fQV#-Y-;E(K_A|Mfa6ue#aAzwB6= zg6;`fe3e7M3cK(do`;THv%_ z15>6Q|3Dn@!?X?-n?2riF0TJ=F?W$Wq zQ`RUk$a;fP;w!r>bC}PP{MkVz=ai9Wp$Z($1hP;A%rsE#?b+qPHkN?~kpT;sfXWk- z1cCN?(v?`K9_Gen^3{=@ERVe`Vo8x9M?T~kTS|2p4z2gPZ@)&vezf43A3>N&SipWC zjDO3i8GzZRVb!Q5A;+#RW<6q3 zhxbpW89#S#MR^q+l48e5hq7EvkwK$+@+u6J63!4cosVi(&gY~1@841TNA37hY(*pj zx;N39!kU6|!F)pB2j5VX{9IE($+V-q!+%WvMrTLEb`X_w-LKjDVG~+|jTp_y!_md4 zY>Xh!wlQhX5SOS-UCG>Hn-igLe1}lvSK{#+Xv+HgDt{AI9rw`}Mu~xZxL|10l3-f} zMW;a#X9U0NNtiXgb;K7YH>~cV0_$Ln)LqI7V^G`UVp#hrR+AdY-k*K0pxc6i0^Kc# z($~G>Zsu^lg-^WC5q=FrMTB+B`};uOs5vO?1r~E|IiPyEIEoQWl&&UG`lkGp?s~p!v^bhCVz4tj(Au)sJi(eSWLF#ua4>f=!1CIYv2Xgf|tmZv`OXZn@U)S|m!5dDfib2$0wGP+AmLQf%s>J zWiwo!V{8mY8M=KxW89#x%ULmYu!_j2fQ^p8>^pl7$eZtv`B+>E zDla39{@N*jDBv$}hIs80RyrKI3o|ve{^V{&kh`I?McE`uxo$R5jvZ$Mzp)e~cXFqG z@LnDz^c)B9oC=*O^x@cAy;$}4qWevG%B}bJVnW})T_gUTYrZHS6&~rD)30T5*C0%& za-ZiSKf2cDm));p2MJYg7b(7}&cC3V?m)XBYeXSn`suJ;Tu_ECgBvTW>9v6?naXP* zfYj1Sf09K6CT_6dTFFdna->f4QF%(Fg?S0|vqRPBnhS-UeRosceT9Egf=*C3_#QX= z-V&3ud=ZpwopLnubZA;44d?f}wg7pleR<-H@zk55uD)?KfFsw|tYlA5j8poi*GsQ) zyTSw%qPYHG?YGS0(Q3_X0ZYM9OoWM&2P^XgD!R#f{?L72`|hareIc%&?-zFXHpZPJ zm=eIp*y`=@VG~#Z_oua#gK%#;bnPHw)1?He%xmdl?i2{Nwqb53o}*JTjYbFZq@761 z!4CP5o6%0{<*R20w=x2j>I!vp+r+RJ) z`)!vca;I`fdL_uLC1PelZ-Eh5#ZC3zA6-ThE@Us9wz=D^`4_}rlEP@TV$>F#jk8Am zl-K11NmH_9l+R2_(QwlGB;~s01cT2SgUQ&idRHk@bMtvoz2DlWW>K4|Dw-ds~WG^Ab&RKjP>=Vw%bfv4s%OI62_)UH@F)pIjzaX$Mn=@^UQG4 zojq*4Z?_w+Q^VS6+i8zB1dWcSHVtm@_Okrq{rnDu&khNbgm9j;m06xgPjxSo%swET zP@W)rgO0vn%!*0xE@NWii-pJ5u4KQLZ?mSzNf&YZ-eSP1lpu>JeaE-OM@Jd0HS(NK z`U+jGya2ELLqPA%BNQ$DzI-Ca;wADSUS-jHXYZf=qg_!wXdoW2p0&xMSq92k47xi_ zY09t$pEjI<`KI!nonH?lW{1s1z+@M*fB%>P$;sSoX(kV=!3D=++fr@T6kvpjuigr6fz_;rnyy zUrtTz=6}aA!}{}dFGlJ=pO-V99yZ#}rLI-Gc|<9aD8Og79y)(G<5R2;ZJGD2H0J^e z+JY06cNmWAmOuw7{|1GOnj|q&dhfXUTm@|05J!iv?K%{f5Y&t2_61xqs8F=-&6c$= ztaYR4r9?8L(|-=P9+HCNb}gFWvEtB4ivt>HG5@_gVZy!r_Q9h6$@AoY!iNsvI8d~B zpYnc3I8cpxkK`0dy`ehie7Bz>WbBktu3bf9#!eeE?9INEbuePE^eMm=JaL$d19cL) zf#LZiXNoz+USVLL$&d*%3Eoh`d2?I#-*lrH_eJj zvK@tv(J*P%622xFC23Jc+|)jwSh_aV1w@o3KG@Z(%@~-yvFEkBCo57wl?L4cI%b%^ zSwE{u5FWOxAjM_6~gq)JTBLxB;4t1phfio=ljAo~Zi*eGGP zdXq1dP&@8>$%@nYmiHW}h)isz9`OC=z15$>81X7IS|dh$hj zP$Nw#5)#$++n>3~8*5Oy$b%QGHlwr(Z#@(-6d@uo zQ`}I`5c9~&@cCfx&6JGL)h(S9#}iT@qvi0V$-mB~9JYW;)*YjN6z5yc-`Z1YWaKhA zehNg30WTa69G2O*2G0;%Ep3n{mV8i`#YKl1v_Ifu8?dq&ZFQmmICX-!kP?PnGrDH< zM{4g8TU`q47P|3RzAyzW*z5OblgElzul(~ck%ET7;fM{9QKl2=XMmaZnjOGhT2)r^ zHx0eh?zC^xN*HrL3m3MZPm8Rd4aBB^_`XY74kU=AO=_6em@Tw*mA^H;(>|Ik?>}1@ z5Hr4_adbHCL3cGyty#thODz7=eB?vZo7FuJ^yq6o8XO;B7`#Um`bOWP*R_oBI^nN2 zL*2XIWO1Q`CFbVNE``0z7wMlFBCUm?-c?D=m-7FBbu6XLwM(c%yy`3hIJ?-P!DhWJg9qmIih$45_w@8^*_^CbviI!c#(0{s33TC_j)P zit}=v)4JM0h5%bs6Q#BvWcncgqhNiD+fiXeBoLWFhtMOO>tXpip>GhB4Fx zo@PUyDFS0%3SQ(piQ8U$FJ(6Xn;485&cF0<@;Hh!1UmsM4XZevL=EJCOjAe!seT^g zXT^Q-q4!;L*5jIiMzoReE#rA(Dmn~05#L1culJ%B1RR)F0XNladN5Z!RCxvz$zP1J z!|ZYg@kITjbmRGe;Qs_(Q1+}QWYrY;vrW^w9+EywHd<{zz4pNxtx$a@SB`QK=N9ehaZNrg{MMc++ZsSRNU6w^L3}O(~EI%RlkmI!!>fXI4;$(AfE~Fby z=}1IXKHo~JEsH8{2;3T>M^iGkA2?Xyq2TOVSi83NeO(0hp1QWtNE76gi#IgKEvsy+ z{&$bQJs;)6^sof2GQ|`qNj0idM5KpNUY2T)Tl|}agz2DdhICZ0rVZ__K^GlpODdV% zdRQ=wkgV4ac>dD5`360H%R3K}C0*tQ*BZLf7V6<^rl`;Q&2o1C^NY#3Qw_>sLbe7+Dg~ zGkR$u9YrKdv%N*4JO5FT$xm@+g`+(jR2j8%YfXy&(8m}H&Lxs^1M$X9v8(Q>~3Ot z3|}T#L9wqqSkik!Zu=jkhjOkOswom2&eBH7=O-WUBWS%a9r58yPwNq7RN1-}k`SM_ zNfiqUJ{OTpGtoPJ&(^U1jYrB%=OLs@-zMyJ$WE07&O1hnCe$BalsemqE)MJd(X7t4 z1lTu1pDRRJ(lFttUz@SE;Qw+`DJfluUG8DL7KySkz|@pndQmZKl^8{sUEA+)nk%Q) zhND3U>O7XSb~3RU{VFQ6irT4o^Lba~eOZ>CQ{>jnw926DNt-T#5iC`_%w8H`^7o=ql^5YM7;Q_RQAb$73oW(|H9iVe)bX#g^eqz9kVge zZTbHbr6-)QRde_ZzzyyE+c)9{aJ2YuYdC9afZ4jnhv`O&ZQh?V^rELb?eT*~&a4u2BPF&$!Jb-R1x#ie-VXX*Jld4ojR+;h`IqGyCk`Az^mi*5Jyo>a zdc~bMv|zk0BT2eUjF|nGg@`57QMY7rn%D{V0P(oV^B^>v+>aqaD|e9ljlvf0|GWtO^YV2mDQdL?ja zlnBh(l`TkLs-!NEoe+f$JFS-ymDnsL%cN^Uyb&}+4xXW=s1hCeB~c8>KtNZA5}>2_ znzokiaR&>uZ$TFi0vB&Q7wU!S*qu|V4T11%dkc)qR%lZ%T;uvY2k5NrM9U20g+ z*g9K9egEM9(e>ToRR8b)$4ClEWkg6Jl2t}B-<1Z5P%1OBHyPQdjjXb=8zN+8uT$AG zdyh!U-rMoJUx#z(bA5mRbbYSR+jU;ganHy7xbNqti%=3JnM`yEQbvK@gAKHO2%Hei zZ8Nt~Yv;Om@B8#G$IbY+zh*PobctAA0^`e_hZeUe7T(9)&Gfi%93ZA*QYdU>iZdd} z8MJ0GeeV0*w?kpNWE|v-DPZzNVobDnTI6{t0(06h(~n!&5D)cgX&<%^(7~1S<WkPi->Jj zFp1#l_M2X09_Zc$qwco-kY>MBD_9#kFxPVZ2a`Ji+_$~gUnlkn<)i|FEO+VX>fA=3 zT+AB7TeAZx11oXPUVp|+yQZE};s-`0dZif>v~gEIq6(@1wC6U+R?iLS3{HR* zw<{Q+O$qgWlCr3Jg%G_YnD=kQ21ebel#7i77Z^HEuJKBlF*wOlC;hxc3E{QwP<5QC zqpXgqjqIs&nopojb6E~LpwMz%=&<`BOjco8Z7(l_zKD863dRLdP|e=k2ac-mFgQb` z?lf&${E-M_*Vqm5f6CI9Xxt$Yt#hLN!34md>S-L>KX_lc<8@^c=fkrDcx3CN2_oSh zpYBedo81f1?Nn=a5l5CquMz+>5SIb?&G+CSW=h6z+?T8)l1d15sOdv)Ne({lf^rv> zaPCx^Y;G`<{FEHGIX~t}+?DYrE_?(nQ@NP}Gap+9KSg!w?iVhLqQ8lminV_G?8_mL zI4?>B=$5Q)Q&IaDYt4e0Hn&>4tCh(B01ZX?y-b$EpofP9QAVFdPKfc1NZ|ko|ITfdqLG(vZy2RVulovk*YQ#h12!J}v!6`8XjBQKL zkgMH3e2DpX(}?ow@_v&MMo6BMey+~;m!R(Ql4_AIk0|EzkW6>xO%e~iY{v3rYmqG? zM01ObqTFAvJDu}+nN5=A$D7$1^<>@v+WL%gl*_E+`f%|Zv+odobxN7_Wr+oAKa3SFG*H0r!vmcQFT~3I%|5A; ziAxYO4xpp21D^gw?%{!xWsSG-8)?AB%0y1!eSyeZK_$wY`aKu+5A^Bmf6tq5<}@(>a_fy$p|q8iry3*q*j)Y z5RR)4-VFOVp&++fOpJ%_1q2+(u$FRvxr07wbl!kdwDrOa1S(HHrvHWIz51OH=m|vQ zz7(ox=@zo=gI;0GS?KKZ{v0lPvFqn!L8(r9um=N3>hblj1QPxu4&)hg7a&%euhfbD z+nQ(!!N_h#$3?N5r0YnSr+;i}2JqCe{jcL02)Q_AGvqe_#}!EsW5O!({L#xoR$ZU9 z{Qw9)#`A%Gnh&M-y|AIT^-w;5`f7zH4}0pOXT=QMLd5VqK8E;wG(?lHs#FD#SOoL( zxWD9?lJhqyw~|5->k*vEL5&_5Jx!LN-wy)<<;DQ|`%V&x6d@q@EPo({)}CxLph6*4 zqlstmaD&E$mU+gi?mM4zn;e-e;R>K;R1~hf(sEdhbRDWWG91UI&Tz+CirrJiKDqby z2c-VQ`?ICG7Jypu@@^<0xGWdqG3+v^m=KOf+$UbRErz6K}(+t~uk%D^5 zy2QxRr`Bn`0w2*$j{}bY1qC+3TNr}lACNsAo02-=DHqMo&UMInYhOo2H|>r{n#jEv zC$fpoJZ>nbnEDYx-9t4=9QAVU60S1!nFy1nUZ~ebpdO5v=p2iEQ2H|Ak=qrp1amaE zgOyE0uD02|s#EwDNwRP2_hWi-zaO9dUvZyA1!j$@{mVVgah19Ek9({kYI_SM;CwpB z%bHpA`uCw`Ez74*{@${`O7}<>p`fp|a`Gb*O@-bd)dJ8XiuN5jFB#rMqe!+6D$_Ib zFiFdqRD<~{BXJi2aBv8cqC|xneDB)O)N$7og|I$G+*s_^R411Nf0sqz6df(c)E=aK zSLppn3@aFCE_2d!!^bYYm2VqQ?5^DNlx0h4DuOn%2*o5thWIEPMaMeTS`n%avJ0Lo zmMke78fRO~bFztIAa~8VL?V-%%w5CL5-$HTR}eQ32ROx0gj#=KoEU0<4H#ONXdEBN z{WaQk;v$9e(|vf=JQ@dw)m;2XxV=kjm3?&NCXep&3=cDpnB=XbL3YvV`~A!H8_P!R zI80mjLHr5C7<@#G!JlyxACgrEsGw_@*9jSJ*%r{mnrt+ap(@+l1uzD&J{Zfmz;weW zHH-IuEqdY`;~)y(Dp0!Wx|YezQ%b=23?JU`*2}YBT{Fv35fKc&DzE3GspD+uJ)mfm zDNcXiK>%h5OXmOP(ZiR}!~}kXDBsoSl>5jGWT4Pn2x=}etfPC*e4x!<%$TsEP4?F= zeg{OmPlt{l?sI<&(yAeNYWnsRlYj8tmx#O8@lEBWLl`YRXa`aJL~25 zRQ6%_BKQ;mC3akTeyB3hT)6nOfHN|6{c;(A@9z-T`fjDrfhe@fd}(w!%uIdN+4mv@ z3`j92V0<@y#_;)5r38LQ=qt1ecWm7CM7cku$c1@h9&*PX=d9&c2&0=gw;LbYq8~!= zeRMa#gv>aY21f)aezoy%&eF%g=awDG9inwBTfLWl3$zJDI)I1W-8b$R#4`bKe%*s! z0*pPP-(N4MR{I5!jV3P)Ka{WEk9h7{<o84pGz*=7|Q>(R(*jo&@!&TRUfW z+t>=hwWI~~o|VEDf+S|4bainMN!oj~=koOEwfD=P`zG}cOc zNSpy?{&9^Q4+Qh;&r$^dKm#W3!EJU&>Jk$gkQPa==2Ev zP}HhHmcA5xXM^hA)@(G%E?Jfe#p|ju#~z`i5K{Y@&# zbWTtallgRPsx`1tGEV*eYS^#s=Qm^uA%sC#fWracGG5)9zjk-Ihjt+w7-~RB@`UmO zq*4Nl4giZG*7$c*#^`V6|bEqOK{<4co)f~!tL))OfZ#<=Em(GBZ`ven|gr6qec?*RV0Azrh z4SWxRBFRW1>Xzpu*K(d8F_Q|UQNhY9!S}D`zUC-v+X~z+p(d=zlyG> z7bbdXz*kwDeM#9*g#zoJIj&i3;X~%Jw~KdXKEyoRZ8Q6bQ6($V2ut|LjNFtmDfSz? zR>D;cA@NzZaYEmfq8Y{E!r}w~CxI`?;COKLS!^ zc;*XILdWqX4Z(*9oVe+FJ$+-=owA^R7k_+@+y*ep7!Hq}yhtL`8vg_EVG;A&^z@n! zZWJCYaw~qfO=&Sle3@JR)J+9(pxFq-l1oU(&M{YdFDq)jAlMlci3+&=qtaBUIL^xh z*?nt$9KH#WXHR3x+Fx=d3e1x3eZ&rl; zZhO*hrJFTx(Jd+oN1Osc1b-g48a#5HeKu-;D*yGDc>4(G14y7E@K6~%{4_R*+E(^r zph`1IhMiNysv$HUGUqOe2EgsRnUGxm;h>WP%BrAdb$TEyC!(#StR)9$*MZBi5w7f{ z{F5i#9^nBd(c}okHoN)0e!AfI;K*h>9w5^KN1g9j$uMbeUzUJ`zI;rLLWo}TrQ+qAc7N*>6@{< zm75d{MB@Dsj?_#G>PK_Ce@MyGasP{YF}h?(_*|%I9w6zNG-yy z8o&u!+pz3WuCBq(s4Xd*FpPOS)q=Y#t5)*0!S#KPR}XsMz&mOWbT=cim1AnRn$v^* zWuWHk!~j-6A8{*Sl)C9QGCn?eeegSiQ7q&<%FnWBMD;UPKg45~3>Dt!Nf3yMq?XO? zn33{b<)&)UOPg?zJFpGuU2B85)&&Y)(FKtc3{HiEut2~8y~Hj3e6ubDxPFr|C9W8a zL;(xIGl?iB<@jdN?OWfDFTFix{tkrXI!1IG@HgK0lx8pbmk^8^j>}u!{rg&kZU-LAmJGS3|G6IlrDCjPr`4|`+nd$W>99#=j#uPFH{<~dpcgG`SHQo?WEt{eo`<$`5T4lX? zvNn)E!b?5cB3K@{cB4Htvm5X~hwhpgY%^esAuy>^{WOA$JuhBVw|C`Gd)m>qgwwl z_XAf}VUZbKo{xO6ynMIbgH&;Q@R$PXgOjc`9C($%BpqJLKXZ%5^#TOF{xnOI78}y0 z2S0p>`?j4OQ9~5>D~0H&b5>@Fw;lZd5QG7IzM56r0DPG7rg_j?n@*H~vh&5SkUzF( z+J=jxYQATFb5WmLt#RM_l#&%mA$RO$+Vm>p_i%OBnb5Smq666(c_Ro0%cr*4hi{t_JcD+%{Kghh%KE=^%i$KW1cp9LV#F`_=&)-B#`dqFtEg2PMawSr8U zP+MGpp9v8nx4~|Fp~-125wkoPSE5pRP|=s8e5-wC7H9LBP?yNg4HnGS+4hX)g%FYb zuEo5Q^&aS%#8$WG>Y0AY7A^(Bw1B8V!M6l3AOsZbT`Ci;QGclsUDG%3dD~lf%QPP9 z#$1v6JJWRQpz95|M=4LCTey%)gE9?}!^Hr}gPx)O&6q(lA zJ9-==Av0Hy(|2Jwi|A~r*ye~&tKR4(IeEf=*B{+TK%w&J4L@WEJi70=h#d|Au4 z8W86J;XYanarFre_gT^2Kj+%*1VBO)(a1zmYn_dWPTqx#hg>Ne%+ewZKC6ra!Ri)K zZA&=@pZbKo+r9D7{h=C6T43YBy*J0dq=Al8zrbkj3V(3Qm&1QMVKzbZAUh|Bw&xO= z_~4`2dWV8@TJT&(FJ-|nFu@A*9ZK4-SXy{*bgVh*y2Q7}Px++uowiI1SiSLJhweT@ zj|rkI-DHFtqya77dk`HL7l?Ib++FbW)8@!^T%-fz-ockSkjUKnl{Yb7{F;fsRq~Z4 zWndph8(XS<@q3-Qph(0dnFknUBz3z#EXY?q*~q}8w{?14@#qx#kDbOBK-XCIql>xz ze9%KDO%b};1eACpkXjg00Qgk7M*n@*iDx9YJ9$i`4Tfc<4Yo|fV@b2R{SRS!D1rxF zdhTT7y4zzcqNO?Kghl>5)9eBITvl0xH@aDzLLX*08>PJBxm%TX9gNA*MI19s@-a#8 zfGP|w!~fk;da`b!7zPFEjhUzJHBaeHS@-ahCEU}g{x}cSqkvHeSEomQd@TeS$qr-H zRN3|&zgiH#ZfdJN{sp~xMYi48V#Z49t9*ksThXDuFmwknfQ5ZKdICoM6>tifh1_Q% z;W(3OF+PF>7EoicHnBOI?SlfY^nh=d+6W%Jb|y)6@7<@IXs2Rj`m)Stln(id+X%QADocuiVLF95DIe>9n8} z!L)QhM8kC*q<}pi`*_l47X{rfv9x<#?7a-gd{8JJq${>jQ$HwMYQT2)67@rt1SR*~ zwj2V(PUB;3rUS>fe(F#sa(LRsDS3bR;qY>OF%)okKMp37qDcvp68)9oV=$A!o@ZiM za#3t7te8PZ=Q@?k;AnOUaQKj#3*b(<<|rfJN!{udi%au23t8Rac6X8R*l13Fv4$@! z%J#cvSYgyZ$aQYzBXC8{EnpUO!TmcBF^o*UU|kMiO-^JoW!nSgS#uo0O=zr}4&rL0 zz#PXpE2fqOoHl?EL#Z|8cSu4HvKjfwM1d7=2nP9%L%%{J%N{?Q6B2lo8c?>A4B$J4Y39`*E2Qp8H}|~(v4cXh%`GLvn>lsq zeyw=c;gl;$haSr9cnGZ`69@4zD{WFYvDmqdYu%g{+gJGx2?L|SZ8fb!B{I$>ah%$< zUo!1}rS4Pyr+{LH+L0jdvf!Ao4|EhM=e}HCSPC%nt?F{`U`Dlm))XFH)tA9rvi zJW%$l5%YQR=HYc&(4-I=N59H5A+O_Pi=m9SB^m+>NQiUuM;Ia;{?1KI+Vm9ke81en zCO!`*4Ktk02N!)bKNGvj*fYOddv&&h9qDufdXxD+q1^-kgYwW<43J@FG;T?n2f}II z^;b6WKXukQUB;yZpENLVJwx0FG+0^uG=n@bYGbzEN7kE;maXF+6UPt(kV403j%W9- z%p;x)$T%fXfbMo(kq+kvvp#km#P*a<;?XP(R=UBJA7>Ar*gU-7B_8D{uG8K!zQ?CN zfE#(U_WS-!hEBH5Rw4*Ku>%npDDv(~jq?5NSk@H%yZz0uUU}G$u+%X!t!b~2a)f^d zutYYvfGqRQ96sy}|Hii?M*9e`00Y+}bhwSi#usCRB(rv}gMN8I@faQ$XNX6_fKr$g z7>3$6)^#{UYvs7qE}ow^pF9aMKlP#s-g%5oySE-@WGd$0MdkrfYMOr2isNGbhN#*u z>mkOBb8Vr7PB^Q(B<0F-eu)o)TI#%It{+cr`C!+1wfY~v58{Z2z_f>APZXA3MXBu6?aVu^_U(?anQs+smLKb#UJ!Jz0;be!kE-8?!#-J zr|C~m<7H9l1|o}UKhA{^cU5}6U3$H-Tz5PDewtq1yYer>iR^#P7?-BzS%IOGuZ!g7 zUK<@69WA{F9L#$TO*;2E*FO9$u|u607_M^jRv9xl8+gia98Wb7p>OP!*WGp@I1Dxr z9$esgfjIRx?1Z}Bw5|ebUaDx#&DN!X%4G_wCpdR(WhupkwA`&~st>Hg2#g!A3bR!1 z5kPXC{)xaw3Z6UqnN7$1zmrR_#XflV$J?Qcp9tdVEk(0(M-CD~O<^La#FWUp&zGiI z{XAdEQONIPKZlWl zs7B}W6|BW9?Iz2=3Hywk6xrvr^2ruGJ~>p?pb zj%}otU+Mbn13$%oWf5T_>%}Wqc`-4!d(*;e*ibI8Jli zcKRyzO_AYN3mY7jbP!fTP-#XRM&%^w+N@pLg`co-9_)W%vz zu{St^7^BMlT|DS=nSD5h8Up=6-w*I4LlVh6%)$|HN4t3Fw_}}c)OY!P1vzz2{dU2Q zFLrNTVniwCXJ8}sP!fxY?Zp^nnx)~4BQbQLbfI{+jUsu91R^jz3^=ia$J>gf>$J0y zQg3~(1fF`lAG*sjKl9f8$q}PZfPH+-9*y>nu4Y6bW!_qI$7aJv1-Dh=*x{w? za(@Bx>ohTxJ$jkucBt}5h*7-ayZ^dN38Z^m`8uHi}FalS+O%6DCM``jcT(?9>ohI`rBcoc%k$uAu*B`9dQv@+%?}6 zLRI8+8Po;|p78^Knn2A?+H;hre|_#uG!3}D2R%tB2mz^0`(MfPc~XtGT~q-aYizXQ zCJ^H-oz3$Rz=Ypz+MCs0J7Q6Dbw?;sgUJSqU42!u*#IfVS!v1rRjT78p}4zeYtB8a z4t)obj2I^fD-X)7{v?@JQCzRQV|%}r-Lp*QA(e*9bSVN4r&-q5RaoRk*Rnla<~O}! zB$dODbd|#}2>BZ0WIy~aiO9T+`pttrj;wouW5YQ+uUV3=&Fo8^h0el|8vDi$D zC`_E~Ej%wvj=Vd@dD$%E0}IKl(b0VM3d{9Y%S(_0Ku8uGKHx552#71vPlh!AB^Dp#(zrSK;hN?}o zJ0{I6k?#Q80cmMKI?Cm>R}xRvJbvn=nX*)v9JGZP#FUUtT;*PO+Bfn}p*Z2GDz&212<3bHvx2qB zY3bSD0L>tjRq<>~pOC__*tj11hs>or*x1AjOXoRGrr0Z!+U!U70dq-Hl+IswM`Cps z@rEiDRbJEcE0z!~xveXt_(8N3)S8zWnaN8sY|w9sU1`cx?23+HNbb^@?-#QH{2w@j z@9qs}IWAXg8DMP+2{j7=B~_AjXj&4B_vs|1;c_Ll2sm;!`X z;BLj1*t;PGb=2ZD`Qn&8GT)No_nhgHEVti)X7885A6=0mr3E7jvblGI6>r;nQYE#p zHh?4N&$##KZ&Pj}z9KkOM`R_^Q4#u`d`(Htc#d-S+YjH4oS-e~+2fDT=R&37%R;E` z;^~n^z^imN%ui0S?0nJeW(F|Pc}m7EU+19D%SPQe;@!D z8M^)7ZEr8mwL;>+9UB7dDUo}%RZsMZ3~2E6ILcq`II71YL~2;KKbU04IsK4QXfQjH zFD7OGS?#mha2KXr+WrDk!vYBT z|K=l(4x4=XrSHVU9d45vw=0PpqNA&eWp+FR>D7Ee5-06vDwYlx-w~c_@N;7aPBsg; zC#0Ylb|iT<5`_>vp*MzcaokI-U|=XCuXz6KiPgyDT?Bkfj%mrkk0><%TZ#i6+FM!f zxk7qS)O`*r_Np!85OW$=T|TP`Ua>v@Z=&`~iaP1y)8&tZV}y^A7>Zi0LlhM$o>eBJ z==_p(yuMTuRd&hgeM&y>K9LFEE{pX`r_{4*B8C_V-TFE)6=c}kZ*D=2*5cTdJe20u zm!e&N@=Fa6b{qH|cd&85S1t+`CMsC$ac8Lx=Hs=QDan$@3JQ3WK?y>2wZ!$7qcjxJ z7sZju9f`v1Ld@+49lG7Z$vo~HF>QM|lv8)cw`_AbKUnhH!lJQ?lP8IfO&M~Qd{cOF z;2hmD-V9(U!M?@2lOR1zmfzomp%8z?T0Bdw3jn5X+F=&G){xc^{nM!sX~WwKNJqH_ z!03JprC`jz2#tHwI`k$JVI+n$2w~cx9wdo*9%uqrY9LL_RQ2U-HvcOH{1;IqhKLEr zmM+3mVE289?x4qSR=e}k@!nOPy9o0nDDU?lp)$K&%!zavfdPQ9IB6C7w=UBFFkA-w zq1(9w`>0UEXNwH;wWcK7UgLilQ^1gOB6cplgWuKZGEKZaDW3v2v#4inH9P0D?!9f{ z#Xt-qA%voAD|})9Q5@C1I**OYT3Q~KJVAHrc7IOoS8LN+liIudBnVFq7}wyuUJRCx z0J{vrAEa6a^dD%nJx}7XTq)*-*FLVt=;B2CPp!S)n6b zia2JOv-cYH;>N_nh3Q`>q^}*=KCR#FKYcjyu%^%$ff>_1GGa5stj9HOT-9DG~>jHT5YL# zW05J#(u;?$xZqFap)P*53b^=g0CoVda)2Jq(u?~Ly2O4c#(Mf5f37+jQQ@ItbN)z@A6 zSCiVQubwCSXw(mYqi+vYm+7uWz4xwmmMEkH7ffR2vnTVTtq^t%oXx8XD;_X-gB@L%vo@NqydvnBv;@R=+{9Mk#4vwNQ4+X=CG8iBiF+UoVY(2p@Nn z>!`HnS+b9o{g>V6yxMbJ5g55e>Y?^zE|)0^8Os1=qpi|mdBp1-+eNUuh7R7x$c_z2 zMeeLAx=-^P=n}lpp)TCDp5g7+kIr-bKB~nGan~vNN2P98=uS@wkh#ry@oY7fGC(=h zu9yS^jipd#d4W((jR*72ZmiAv{QUm%?P!*+cJ^WE!Oe_TMjt_xoy>)+GZ*fX8t@bQ zUFD7xvc6?75V+reKLIu^!c8Uccmp$kq(#p))7HDzxqE?675i9|NU4g!(1vKUFtw(5 zLem?-h&SNTjj;gs4owsk{PX}?WJI0RQJT0ufwzk%@Aq%iXdP}A_mmMnKD834+^TT= z9@`EYP7uT?G9(xnPIq6rtEu6L^y-=VmjGj^jQ^AjY!&tk?WwBad>h z(OMJkApTdaUZI0?*Q(w%ybB~EV`<%z zqnyZz;i4J-Xz#Iu-&)mLLns5=Fs9nGVF;lLnXA)oHMuxRtkjn~2Vg4X(m;J2Wf{g# z$Iyu}Bb$hbRE~F`L66Ax-}UsI;pBYzIc;lbI1yPLQs;K{z7@ywj(*v+<1?mjJ;a?6 z@oVahrPwR%rvci7B2!$Mg7&JM5ZJ$t&x{>A@2}S;v^Y_ zM!XiD9@yX6Me=SoZkL4*sY@>iqY&Zwc_bLBvH$+x!nLXFwFP%?B<$nG{p7S-0g&jr^d+&f0cH^-X<0sgGb3v-O3C zMwl<6DG+aSee7kHYpHR4y|IMO!U$`?^{Xkk}duAjv@W2=isM z&mocN=;@HQ<=KkARGTr-yz|PI-_vO~P5gDzZF=ac zUX9IJYw+_0?F+T75u*&Oz{t{T;+i8Se1jR7M?S$s)dhw}kzLtNq%xi_E8DoI2$1vQ z;iX%0e+?5=@-pL&SxwNi>l5c%5(m6YQ`&AWFv2;=zlY`wXFBt@5~PFY|6-ocCoO&fzc>GGrz!d_fk#oeGPu z(mfCFiDgfeXIr>>I^_|OTr)UK-@kBmyX>c< za}F0#hWZ@q{$7Z5%q;!b-FpyG1Jl!GZdZkF)nOtXy6K=W_6|a22(emXiTX>Hl&-y` zx`>b#pnV8pfLpoNp><+?rkckIu<=vjA3@4azj8w^!BzvMi8plK@r+$G{BH-@ss@>0 z14Z>@M``sw2pt*R*XpESlzP*PyBYpn%KVu5@pBv+8JZ42d>PV;4>O$tTp8W7etUl^ zQoOasdiakQ^bx@5fW1)Qr5mQld%&0^vLf&q|1HaBFDQ}L@e)%g%bCw>Z~<3NcsuZU zC;&DMOL0qu*DPq-uEX}5w(EQ%q7yu(%CPmbx5_Y2>9rUp-TRz%>yw!`nSoJ@oE~XE zdlCL2GR`j2eaZLs7o!{c1JTb-$-DaR2IE7oP&JZCiR{MsXgc)F<^E3)ScixHw$n`~ z4c>o{Dp?iOsb7mmdS%D?5Gg(e^WO@~S2Q+?k#S%!j%#CO$lcRW zYW7Xg3x~rgvSY7J31og9IkHROLu@RDZbFC zVCb|_CfS7`i3MUGf7!mwbXacdMc#$g+pOTH;2Q4xua&Qxxvhh!A;uoTgWlA;Jj*T7 z*s&*j1V=3V&^@H?e1iClA3`3Ky82?~1l!9rL4x|S^x~{i#vT9g3Fpt+wwLeM(>Nqa zt)-p3q#I~RlHKj1azFd0rK#9{FiW;DUa?0XnUiNfj8?bE?KpC*13zX84w06`U2JwH zKKc;a=5oIwSiq2LH#tdGjgz+hSKXGipc^+_op;~Jj`q4NF+gJ|WHN3rUWIhPz?Acm zH2-!y7r?i8HNV&s!7u_^KLWMN%QFovW=x87rNU@1WV{fPcEBTT0l%r+w&v6#*6XHV z>f3G(jm?dVvs2GRO9)&l!?%ktgtBhAn*ZGMCLI9dms)6`E`uQy39nMqqF=p^6+$|| z`HI%~wY{E2cD(}{926w;X1}UxNlW~=lRv+KP~r2kwV8$^9kjRy|DH2)OMYB(2T#U` z(IMjmT?O)WZ9ic^N=7`aO_+RixmljXI;nTGw zw*Ch`sE)SuNqvR?$}g=V!%0^1x)9oH*uJq(!gOmreONnq3Z_e*_Dq}Ha?!2R+srx} zgMN}TJOL}*G+dUXSQWXy`C{TqymvY9)O3V$zuAQ$A=sg<@;PzPijROTg*-sINK_e3xt#l(Tsacb?g>wxrXFR%%hn0AOjl!;lwIR{58 zqU7EU3#lCOsF^_46r0oKMe+v0RT#_S<=W{KZLD(A-G`UQ53T`li*!0<4!A*e@FMs%BIuvBi^YoOO8HBmSNTRlPR_HFSm8iHp=VpBS?L7H39u$2 z8K*x*A3)n&($SCHcgb6I&yj7>(trd9()He58%^ujarL%xju4HAYQ3^BPp&fqjghQ(_= zuIt&@oqpEUKf4Fl^sCvk*|YJ66z^Y_(CA2L4<9-;+{r@|szr*usO)HP^`7-XQT#E* z6l{S-(~|t!;y|tKbsO(O5R?Ow(pTnQJiWaw2gv_w5wU_<8_j=%t`^=UtvZapd7y1b zL4#>W=DyWT^^kiZ+E^S!ZeS!a`Q>b#xZgWJ_= zL|-`ttna0MX_$z^W3Aa+l9?u)g?H+h8e9jPf867;J{=8F|K)3isv|+il#0l^xve!PN;0Q~i}1gOUsU$7jqm35oj0>fOX;N(s;;uE z@>LSv;n}y-p(Iyot!^O!=+7%jB1l+Pp*#45h*GII@#v(L*L+Hu$f!^ z(Nzo3`QRnJ(d1D2io-iG;!oP-fk^x&a6c)NNo96Q`%W8+C#Lz>)V|98Hyn>k6}s`F ziJsZ?@h5jJN)-JrS<+eiZCM~l$KOh-lgziFVZJp_62H2LGK{0#-+@m-@`ZAwRkY}E zMO&FaFB0r82TqG(@!!QP;;zEKd;qJOZG1oVjN3d1zZZo1XFt}C;{Ryt7jK?H8ql5& zogD3CqH$az*9cS|X+g#C5a2MV)HH1_F`;y!peQ*7#$^m}EBXQh?G|r?o;wGDE4_6* z*twt8v07}zLtdC!Wx2lKF-g~SHH{MdXe|aETxL)}w-A@1`F!7)7d!-7&R)d6H7T0_ zP9zxU$mYuBFI*yJfpLxj&B4ut=mfgTH+z0VufLV7Pw7D_oevshZ_3ZLYgZDOz&MS9 zp4(?Cav@SKmCZaCV! z-MqTcuITwc2bV{7t`5I8sZ$Nkzb~e=zo5cv6h>y#>lV<@8s@pazo0ptWVmhy-LF>; zB;{Qd(6b!dj_#nLZ7&&CGIs9COrYbqCwYVzaTiA>N9K;dIGc~tD1*B?{NL)C#kv_3 zS3kwwV(ZXjP{##wayZvVjnT4BZ%6ewH%I<-&9+J9TjLi)cW4MA{7UvD(Oj23+q7O_-W;ETK}J0XIuHDFmp!<<99Guguc~)j z|Hxks4<7EpxHv8>EA621AUC;_13EL_quol7m`$B|E3e{p3Qxn)W174U9T^$ zsIaem7&|?QxTzXxu`u_fE)KPSw|VCb;#l;CxMn*kXwE`;O88z)=M%R7IRD%BqMNmw zVddNWjrCDrg&c|o^KezT&n32D8Xg*Wn}IWvfDcGQ6)3XEY$WlR(i(19L(03|*%yN^ z(h)8d^9AP$ls>>aZ5E@y+GWkVShAu_jDIC~OLu_sf=yl0*K$5e9Os9z zRgvrC!~DnflU-~_^zVSPyb-213XP=QVS0z#(9`0iJ(xB1#bveL^TeAIT(>NTK?g?k zy7syGn>1W*q`gAbyS+bnlx#P~%WQU_DSaWvnweQTtiD~!jb8uT$kNtY@`}J4#%)@L zgK91=u#U&=QyUBLX^&6sd3~4Tgm9(!)5Ca2uS2$#!#{GO>3hH2Kan#qQ>_Qe@Z0xr z4RdQxYtIg?29E9or3;iKUOo@L?kY>_#ft9XjObQ4d%)y?3I6Y7uH&q(Qpv!&nfWZd z|2WVPqG{k(^>^*QxuyB`ks!|WS{KIhP;s*<^~HWPO^#6PgYNqRV^~4XQ*)-=d>165RvC;$R zA!LENm-`brzf@f}I$-A|fd54d<)-L0zgsu6Tm)29V?IHx47l4h9A`r1lS)6A{;MDw z3>6cB*aF=UXY)exwWGw+uarjwa)0UC1PHtqAgJA5U7Xr=@O_|PUMF+%E17l2=BDX< zCAPi&-){jilo$#x4>)hp&qmgZeLVB2*i$C$_*6Hr=fo;lZT#gmu4-(bW5nhdP+7kA z`qC^1W@E1t(#uyNi@`TJ}QkeF+^NulxsJ_5Jg5}2t5@3PW$ES+pUT7V-+(;m+ zfO(w6nfRDLr7O9e*c9yO@F0w?6Hq&=z4M1a01wq76pi|m7L{$m-Wcj*snE<-x5XgD zt?eVw>7l%qdoqT8-4!udanjb0IZ>WP?>JV>_%n7c%)du)J%Ke)Xo8hgMegyd z!Y26zuaF&~6%raM&7EJ@u-HJzY|-v(zPIxpc#?*ZGXA?snE=W)V?p_%th=Oc`_aR( zFv1bdi{75Rgu%?=1VtcZ*WBp>L{JK*=Tkfp-XwQ}o;nBTzhbiCSGelYns{<{cY!7U zp0~128S)3q0_YALNO;spO5sIv^45bFS9D7Q8XBw*QFxv8-8Fht*H9_2_yrZwe;3d1 zK|lEw{KeVQ=FH$(@{SEVrOQ_2?AkrUW969!J`~GEtC5Qms}cEqiw^Z6*=WlR{g)%f zpJc>%rd)h_6-?(6LY%h6<`5YviWHR~@WFCH@Dyv2I7{k=TUthj3JLEdS=j94+V%hi zur_xnPwip5#y{NN7IzCReXGBav7NDf$C;bu5fPz?JPfGo6KAmUbi6NCC0tY58yao| z8~D|K68twp{$<*~$o6Dl&j|KCaqMSIOITre@7T>V!UQd0*pJ$eim&o0qAi~u)m}d~ zk-BcrXtXTyBf@zY-|U1FiYi?lSMr}98n3&#PL1)TDv;mH{hj$6&ssu6EZjl{C4-8g zeLQ<|{pF-4)+c8uSUoWI=b)|0bpp(Z+sHDbqv3+e{aswUO6>T$TsU+Ks3xd~pdksw>Sc62k??{O3+e|NcYJv8wz!R(J|NFe=F$G@IlMCvWFS{(WHLIvtqZKX?`j2rYy`l*- z)S>vAHs%5C**ovEvo59NeIle=7@2!GDexj$wm;HT?{0Im+oH={xOv7bUTm_CFkBmP zgda@$dm9zpUHiv)ru-P&7~6ITdGn?$F@K*)z}8H<%r>6YuL@KRmy!y3#{Q4d@Q+6K zJ>Sm~y?*fyk=#u*tI@Adp9rl1@HJsZobJsC6ls==q?vF5vN=n;y83;Lbp&Cpi+mqy^xT<|cLbRcVKSj-cn#vi=5k&pX=CW3f z7Dbk4-*0%7(`AugR#$hut^Ha`+6vtY-HvM#@b^G(P_jwH3LVs86&l^E5sZ1%P|~oY zbC4?&r_1*HDr}cJii>^@Tf=ZVzInTEz3Sip6)Ubx^zQTIm-QuY+E2xP!kniMSJpNY z!cfZN_lYLOY0fW!CzgwXT6RN2)+5cIk{|BTn4P1Uqrv|bqG?A}NK&|JN!xeVW)XKi zg`>Y|6cfMm=2FWQ9UfKbIc|d*WK-i(b(PZrOX$Vo6ASb6c?K=kwT-)f?A}gJ5bZHJ zZKY}a5hG=kMeF#cRI_QI)u_>kQ14}M4Bb2~>N4Av<%5aO2L8rHGCDgsv1IKVw;Z?p z$H5Vig_-Htk>5MoRhdr0FS|>s^_OhS;rD{uBJKYa_iGVkQ`|>Jy?#0m2ws-vvA4H3 zu=YuFXe;?QBYh<>^?d)g-q=v$RsW7#{deiT+4<-gZ;>6x-#7H~A(rcdz@hr_GcQl6 zAKEbJi=!Y4TwD3X|CawBDdRX}bw2C0{_ZH3U0GCYNvAp=dJ*ETR3<@U&pc&D+rZ_>}yaXp0DafbG+$dgp5rl+3-KL`>k z`vO0daM~@luUF;VZ}}pZwEZ5VP8U)}S2)5t@P~kiL@E)9!_)mAIi&Y25M?A`!bLn< zpWSJ~zlM(JtAfx`XkGMg6p$#dKMhuAWBwh5uDf>hXd9FODEjvF^du`z z+}OEO(16<0RwlTGJ-}Th4a(nYTU>Qw1aYTt!<-Z6*j@d6()W4Ixw9Z)B8d%yuEI3` ztP5R0RUrNMYASVz{;qxGY~3jxU7-dk=MuIWC>TthO_kBUtZ#_?2gE_m)z6-*mhNNeH zi}1t=F4)KaGb5CG>Pf0yW0MXmU;f?#g^XrtcRFr|RTS3alV>?wxz&`{{%_&Z`@HIUVL&WxE_5WJc73U?>vy*Fw$bzZ+ znEl)qZXhwP3SY{{h}m(Me`4G-lp2vefgMCA)X@ip;)UaP$ca%B*entQw#POMw`~^9 zr;Qa|<;MQ}F}gHc?rd`Me>^P1ZMyUAd>x*t)WJ7*^}g%qYBxx6N;pASIrv1eXT{Iv z>QwIaxAtkUMo&E{ry%;(1XT#TMt}aSKq7a>V}&eY?WUO4n4R~R2KPr9 z{FI_fzdueI`(L-y=;yEo)Jznk_rG_?g1ft@v5W1ys}qQ`O&3J6IufHswuADcD5d0| zDfbFzZ!#>c8Ey!J^Z;(q^ob4soxlhn3u(PwX(C>u4P+Q+fdLZZ7ENuDa$w=4S8zSyPMvoK)}6 zl&^)hY77j_cMmN-T+vEPOq91x?yMCXZd=|FD0`qUG2W&>-_DU}47Zm|9Nqdqhc%7d zUcjzBRhBwG%|**4?cMJlXZo*KUhiK}D?SUodV%~oyC}^9Iz0h1A6DpNxsdQFY+LBa zQGxDDrXG_==%C?iA?&Pqrw~=1iRhmQ?O{TN068W`gFy%j_kQ#|0t(dvls3wy(p;R|W3e?6c^`uca$%-EsV0BHUdcN8L+&6H_F51x0F zvAt{ik0D^%kE(Ds(x7~K;uclu(07M&XF9A1c15lDg_`EWhifzR(oFRm80SGT!)RJ< zrxEVy_F)!gzjHHFOA&fe%-S;p^ioUp(hC99WtOh%uN>DirQN175fK0jbz}eA!jE3I zRtw#tCB3~-mxP}b`2Fuz6-Y!mglQJ;!MRs5!nx1=bM8n2-6@`AKA&1Z^w@tC&jnyu z=yMzOfsrSxs)*1o_seX&`2M@ZP(rUSr<5*J`dm>)#u=%hN=2`Fx7L8^p}1lpoTfx? z{7N`mFvs`S!?Tr%NjrD!Ys@7zwGd8C;qV7_iL=`jHE=dQ0;wmT{0L}i?)b{7g0>Zo z6OP*<$40(HL>{fwhU$`Y{Lj8h=l^~_tvOsY|CMOnSKN$M{MO=BQdjhk9H{h~#PanL;%AUB_ zrI487k06ttZ-dWv$6X9FoK3=E6igT4!j6PUdu}WxJCwLCcI4}mY?Pla7#Hc1Sh6E7 zZKF68okD)%%enmlP4zrQtv@XnT=UlBiz#<_bHpt=vLjjNb3-Bi_Y@ z%XvAqB7X9mqyu*~yE5ChDt>UMs&KaVH9DJ*iowvh@gnGz=# zjd<<8fEv%MTgsiV-g>nRK$=fZFB`G~8%072Bac=^J@u!S!QX@1!CjM&MW2WG)E` zan{dFQ#{L$tS7ZMHK};IHbH#J67AmTIjyc;%+AMJ)TKr$#6@Y<5v%R>Nt*T@W!tsr zhQfa`?~?}o>GwZ=le!{8%T?s?ZVBrtCzO8V`Rq3C>K_JjG((2+nsnf2i7qas3s;+& zo`{T$wD#+Xrt4c&94^hDEa0HK_xzUhowst*De-*|H>MWa``QfMKbIp5-jR|_0ZYf= zjGcW9+@I_x;VZ5z3eMYyc@hqjOBYR^jbFA+-aKgU_0ML8t9MZ+ZGPNT~PT2t%~R9C7Wt z>2eZk{EB4sAZ-ZbL0PSS7A<@4DOM`rN-wqX;kHZMdPxGCB56Dur4s2C zU+D?4wETQ=`3n;CA6OCKlAZks8LY@_L0^7Cn^L3^ZrG<-+mp=kr?M zzWg<&B1WBq=TWk$_v4rybgEqmo#r^NUD1V6Pmh-4k*Pj8CCc`sFXZ#|uUWaL+xZ2I z1V+iM5P3*0wrd_YIL;(kSSV>-H+E+lC<`x4%b!Wo=p6rs+L(^49th-CVd%P|r=M4} zn&ig1GrL9m2u%eAE4U+@5D`gO*m&_a@3pO z>Fd>L-(8}6h$JqN6JnXKXrfk=)Q^U5-~MBaX$7oQ7F3LP8Qt0Cn*vaSOoPn!0{*L?Zj2FsP>qMa{e$E>BM8n_%p>&`bG>m0ZNPB;d~D3I;QIp4?eq zGRS_4HxV;@0F^TRc6vLLRx(D8iws5AZFAui_EAq%ytPlvKuTA%!#C?`fanepx66?0 zEKl`J$B-=gfwdURUEID?#3C#+a236ByGC`up&Pt%g|o?fv~Uk5B+JJ5G7-Iggq+?DwF4sy=7uou-rd z);XOV?^jnw^PVTaz;`UD@P(0q$lEu@g}3>)KBVLgiZVxxD%Hu0iz}b3QCN%2jh_VF zqfg13naQy?gYTp~LW%Y{ns-}2A3tpN?(5y;HmyOae`;q6LROdLVJlZ!Y1>acnTl4p zm=y3fVEeob6XeisT|A&!Olok@Qw5Sj&MA=HEO|;n=MfNp&mN98VsPHO#$x@cF zWGl>+t&J($*w>JC$TrNF`R-?AX8OMK|DX4q_jC?s?&o^0^>_WQ>qZI_vP8CUc%S9% zl|n9=bb@&A?JmnK*#gZ% zjFPISmsdfXRov2^r9I4N=a&0%3Navlug>`Jnt9FqY~1l!E`!FCjm$(O5Uma(%ULOC zin_48HEx|r(j8yl{-;{P{M$Qk2c@%Jd|cD~jToi{ZrKwL9mvO_NR(NE8uP3p#tEUjkr*B#zs_vF&|UNu+W)3Ltu_ovIH8y-qL zl~~=5F83rEM$I5!j8=~?pAJH4@czEqy`lV;qUE#V_y)rhf#n1XVmXx}AL#5iA>}&J z>e6x|&^};xxumP8qs61^#$;aIo2P=TwyYt<9shIoK(6Q$$2IdC#VOW8y7uNaiOibO z(i#|Y0)+cjWi!hM*&g+Vl|NL+=Y+6f(f%=BoEO%^7S_xB4`!oRUze*Thz_#^3G{m& zKe>*PcL?V@YpfpdQq7;Xl%G|w;Fe?hMv+D!|C4Niu55w%;|Q+~M*?YpwotC5toG~{ zZ?e+l0+!g7hs6*NVGi9=PFG78;b0ALa=bY#>)xHDiie?uQ_7%=+p<^PXUIP(s#NyN==>P$ygH99Ze5l9#zO57J|7&Hfcd* z`Tb!fIbIr-2NMRWD9Z);!2WeyA0~?I$H&K;rle4ncxCsEEJ^ui0&W>J`ZC*>wblrl zp)(c!6DiMotv1S-N|pPupMK`B{9pbj$0sMl;f&TB|Eb4nJ3DEPM;$eFj!U>iomJ&P zr(Ypg?VgK4*jGZ`-JjoOT5 z$ARbuIy#k0v%7TF&c#Kp-nX$+2{g>~bP%Lp`_u%}FTS|)-u|V>k9d#doKcoML1Tow zEbnI}l9u;a6FhYe*?mMtKRJB*v5%jolTg#)K{ zLcGn)l$xZzORwplCRMxmb)7mk6gy`q0V?d6gaA9B&2Oqa8U$@81urA#SR#?w0R%z* z4ry%pn)xLFvD{B=I!VYq)X9jsk`Az?-x&WzvWfSqZ)-6=O)#D|pe#@Zjt|5(YZ~5z z^undX!9?Fr0Ki3#gv7Rd`1yh9g3Objaq{ob&(uhfaw!Wvy;qiBP)(2-BVMh@=A%*Z z+Odg=;C+cM4rL389R*x$Fy@~${n-CLw56PCOwKkY+hw<~yv@UM0FA5oi7+R|c&+Ky zeYGYzI6Igbok8{v8_m3Vw&N)ekTHSTh#k`e?9yp|@xV33oxDkZe(j5<8rdei^GwJn zzQby6iU`_HfX)$J3saok*T}p}?kGxDuw*(XG&e~5_iTfn3xv%KEv^qVnZMVV?z~gB z{MQE8+kUVAoCrixW@**gMePl$5|zFeWyacEM{hB8{n}@`^+KWC?Z?$C1cu%^Z-&+t9K(ja>o1p&Vlhqg zO76VfaA0&6O z?@x4}!fZF?UAsg!_3c8jerzE69gj+FlhGQ%>u4(Pr}cH|^NLAL$mP}FL6^bNo*(j# zlsMb2{Uj|uslL9xWWmh0CN76&n5qfc>ustgBrT4Dy0$Uu*n8BV2kmx60=$KO6(5ha z>V15M2UQ-iUPS@qTm1Ho{F$5=9)z75`*Pm46-6UsJCur4J7i-;W}+0JUkqZ zV>RDomaj-tjRJt)D+(X z+X|)%ytaW=UVID$w{4J1!gEDOBT7}Rx-cP}M_-XcF5lsD{1IP0=|sOL{T@H9^I1Kk zxtJTG$F6CLK+e$E40(&qtVvybYSr+?$JOlbg93^YJ@NXUG`tR)zb8JwFjq||8|8Ba zCGyt?5P!F)Tc=~Osg$qdph4wxR_x-uL$AcjsfVJx#chlP)-zQmsv!{4uwx4>xG?hB zEN40_8$e(5J`2E2z?oeDLx(cKZ(> zjOJ#$7#>j-fpiHF{!Q%3u;&-uwD3}7l;sN`gtpuinfK&$^8_HCc&a+!lXN!%YjU!1cYpj-XYL1OIm8B$b8pM1A3JueAI9P^W&OtB{CZu{ z`X*!aB=4u(P|ZP^jj@Ukwye5Qjeb}ccO+j5kEj8Ou4CPv3aj>V=YtOhz;xB=vfrN~ zt{vvcb)VijeLyGR3P=Xt@n@}Ju~>jw_;Z?FBTf;E=KSs&R{>!bWv>Rx4syeFI?(=a@6QdoZQQuA29Qz@x`Jlq*ZWX_XWga&t2oEB zk&F>0>IKBX{jL^gJ-U;6r+oc3wm|Y#+gBuEL6+|=rAO1x)pxE(xFv(NPwLiAdo>{> zsWZL5>CIIIa(m04J5l;T-2SqRP$IY_x?= z@u15h&(>pS7zJt*=%auX)+|df>}5qoH@K`iUZhrECi5CyrTr!{@s9wgk~6JfGLTKQ z%W@w6>BFoKZGzC3SHxRe#FjIJ9%8Z;H0D>lQ~XTYQ7h+$?%eqrJslk#zqXNddi1%* zm8_IFoZw;tD}!2R@v8f$`zdsbz;nRQsOVgrXWEBauQh@NAII7xGQy$`hP z3`o|3hAav%PXH=?j4kj>@K4eM0#A{aM}sNu6_Jwih%$=cC^%5;{tgmaO9rQ)k#KI&I|{rrHT*Z%2kZyQMJY z`kUF6MyduZqANDSa9BW}TT)060?`sm{+IdmLj{4a|0{k;?ToTm-TLAg1~~f~(6Hcy zWruay#er8oVWAH8#`mBQ3?ic;5^4$Osr7M=6$-^1b5TDx5YVrp#DHsoqF<}Wy_wq> zSgzKyew_cL!SE~H($KpSk82ofi*?yo;*XhU`-SYdEm?7gCy^gMfMs{J>PK^g4NYEP z5CX_j>BQ+jjW=B#ZmH~3D^UA~RkO#bcVaOdv(u-XPifD+DfM6k;o^^b%(^b)HGqrW zu|u*x(;K8lz6Z#LrRhx*{>UmV`p|hPU#8gWEnR*XgEwBBgAryp&)^mM$Lq*80$X*@}(H=XKZ({0P~u zJ^u5aIkZue4@tuw>Rzmg5n4ikbfRLwUdzlGMv7XFvZdBx>(m*l2_Yi>ia!sp`k<{= z&Yoz1x|->|1$RJJr4%?C1D9_+uEPnQj{#S&<%t55f&zO)`5Ii^+(tAk{%vf~hRdu( zFPoqh!Qj;weOgWkwVVj|ZJ3;#H1Hd&f=z1i*b0gO+AReSvc&%t%OTGHejTsy`~}oK zS2*rqxZ!W+NT}GFl{c!whVytCO}O8G4m!*ig`D*IT6;&(FLCJnH#`bBFqMJ9 z^y@=oPzW!MOdTl$tqj5+qxZ#>!S4T+tnB5^0@Mm+{B&VTPBFW)%jS%#(;9B4QyBEI z{V#z~+}WYXttr!wDmN8|pDzjjT9H06HWntUzMKk&pz|v0l9j@bAjxkHRQi&~hiirj zvMd!*AX-t7*0^J7 z-wPi9PHpdE1$Cc{aGRNu{D}O`)rf~yggwkY2tbE|)^+hWwBPw?|Ef@*0LVMdWzu+{ zJ+JS)-%h$C{-8ebCNCwWvOT|O^{-cAi1v|OGf1q5EudVB+R-T_9gc*UuwSz2U&*MD z#OfYt+n>qM-#U8z9yFA6q!`F%472PSujX&B7i*qsx*(%YAhPU`;(qrX_i}FI-EuYj zdg;8g#zkipROD5dUoK*`qB$RrfMR`9W2@-K*@fCoWi-|3b!;Y{BQgE&Y#T*9mn{zR z*w+Wmz_#<(#x)u^6BGW5JeuFLVS{l{)sxktMgZJHO-~OGk@I+V2nY*5CaMuT|0=pQ zbz-Zli#bj;`_sZZ9>aOO=`iy=0vT<%(6OByzWxDxt%vT(+5T)U`z{!yg-n?FzqAvG$+F(-8&QP|c_rdNh7Y-)ff|Fd|NuiraB*tT?n@JCWpTQv> z?e<3qdA$FQl#4D`A6BRj*0y-p2EV>fR zi)#%@osQa`D17|*aRstsPFwn=Q5Gko(akB7;UzFWw|L#$imR_ePf>=FH5+@bKG==1 zE#Mt~rF=oi>-}-c+T%+N#UMznT51O1(}Am@cN{=f@TH~0KhJ;RbfG@8#R!(tfQBcf z23$MVP0#8MzJ7Rc4-Xd)OW^x&1=|rV5bZEPG^S~XBFjiUlAGB}$o0nk73A@*eESKo zz!7V$%90S^G;VU{yjeMTG8dFZjqv#lfpqTNsT0?9xWOG4510gF4Mm`rKy7N}V-%}# zplQ{guI_PT?@0(-I?`U!tY>WHR^AgeGv4E2$|!fiYXl&6$zJSq;cnn+*rC|5*n>>D zJIafJFH3%t@}}SoXHn%;_o! zaZtfUr@Rt)7YzGp2zSQ#AFm!sf1l4mv)Q%g#9D(5jz$~i)8dMb%2IDvNQwtysN~^+ zUR+?I^#l{MfK{DFz}W*Gt- z;T)tfg_`t}Oi1=na-Rdhf&bFJN36oI8IHm7c)7h6d3=n zkxp$#&~}_`RmA|mo?Xn2xs(^&cf>Z3`GCJx;zl3_a+x?VP-tD;0Zph0=3ZQ;X6< z{uu$-O_#C!r>6@ps&_Kbpc+zI$T|YYnkx^X5Z(HRYNQG!CAlYV1^7IBPbSI(t^gW; zY(>~u$i`PP^HQ6gX4=SD&;d-d1u)ak9)w(yYeH(y@8#wO!2kv!q#yh)RJp4jP1q*> zz^i=5F|W8deampQYEj{W!JHlw&H}g9JZ}tqkz4#ZgMC4dqvA}U#OejmAWk2EZDkuI z5O!qa7ARLM>rzmbw;y#_&HwTn1azAB2FQg-ueQg7cX_n8;Fwy%g<(ei0ge`?=yK{z zf&Ihw!1dC_C5m@kZRG_d8N%k4?}Ur2K-CwjhDUfVCRKQp4Qchr`g57P+gw%||Ir3; z+JZhppH)kbuW=8n3I2c(`{d0f#t$BTgPZ03m6~codt{Bw?UiREGrmWimIqg{a zIQja}8Ac5F^*4+C9<&BB@qOzv;j`53TMc?)QED7h^l2f`{VY`=3u8EsGGZ@;@DcL1 zM+|-H-W9q2JGVIHl3RLg-Ok}=9|pelbw#s{4%QkSB>Rkxj)olBcuXwEEWmk0bA@C8 zI5s(Kv0jL#MK3C|oVtB@>g7eC9|dfD``IC^tnb#!VS+TXY}jAG5Qbbj=Ut1stQ&eD zxj7(vCm`NH;o1@}E=G+uQUaQrhvHxK9qu9edtx!n_|kNvcZTQr2H((&k^noH%tXY6 z6@@XDoC1sWfE7>*k{b{M?ZGO$qp3?vcLt7kE2;yuCh8bRuBU_2>Xgx>rbqX~tVKJF z4=Yzu@a+~cnq7D=u9EG?eqLEt|fc3;}8d{Y&Fdq(&TqL-CR{D zjTsma20HFDb-cmHInk?7JH6;?`X$gB`%Q-3wV>&iP3jIsynz8hiL9GFt}D9uO91U| z3xvb@oS@u@ym|bFVMY@88RQD!KaZrI_d0{|#P?$|Fry5Tp|-hM+d84k5W2LFUwU@s7W1`m@PJO%fk zglg3mek)bnHUx?Xwlbgg>l{e?kX|^Q*mCJxK#@I@X{m9aN7@l*(rRpZ1$;o<%`nCQ z0;nB;GXz6)EQtJpYVTH{ABj}kfT7-KKAX+)21~OgS-`S2wS9RA2&Jk^gmx^b8jsChyUVY@78YVCPZX#YuWuxIOo zg{ulmO_7Ey_!pISGWS@uKS^cJxW!X~r-e&h8H!#=KW}b;9a7zn(Av(2Ql=Hy2N(eJ z0No>cSEK(6=VSSnN32w82;q20Zu80Kv0o_dH(CtQl?NM&q_6OBePp7KXqp0?Aeker zkpDeHn|gL1kf|7JX?O_1$UpvgpOQGV&_OHjNqblC$4~HQ1XwUGAqO&n5A~(FG1gbm z1B#ntv?Ifguy_=n6~q;pHXRuYmWNwcMeh#1Aj&Rac1K^uvgPBXF;9ha>QM8rD-XlR z2z=TBuNa@b3dbmL;7LPL zhk;6Sa!ShX5#JMboPHt%a5(`ZGCw;!)x!>XT&g~DXR*h)?A$@KIwqAPM-8KT%TqVeZJ3Tn&0ISEzShb+Lfh4G@nQ~Ri!Yc66CjDQ1wNJ_d5Dab zDnJD{if*t#Zh($P&uG8Nqpm2(fq6LZx$FAZc5zHq5>fm3Ya{Kl{bdOBif#3O-rEoh z>!2oOKK$=N+MoST972Rzo32~4QY(32EaU(rCkST&j^%u)I_*VC1G)?M zL#HDWMc1xfo6z$xzK5qPj^c(bW`;JoCsAm1F33{;BW6Zd{l>IJp|^s#y!fiH=$$#( z=WK>PyYRGcS6`|q0aBg(fbVu4{M-*@4TDh&6~m@^q&tY=<)8Qu|57T>?I=M)$@Oo% z&&Xn1`?c++?KEr@8LGSk_Q(gIUeLFBH2)~VG8uGS69d}c3vC- z<{50>5v6_-QM2gg#^^bG{o7pR22n-U7sDg?{FsMb+2nS8{aqZn4qp;n@v5FMyGuyL zY@zi}k41v!Fk@f1dGaoLqIYRGWhs2_Ye;gjAt9OZ$pZVcS0Z8}l&ox-MET7*-|iMs z0=#lG7n=z@LifS3_~68HP_tI?TUN4wt~k))xw@)?J9Eu^cbzM9Mi@A^obLI*`W<`a z)?r;rH3bIZ0B!vGE)JJ&^|G$35QVDywMX=;d)Jg<7o&Uz3=<$|g-;lwIm+o{b9t}B z7KQbzY651K;KErKGQTNbuR=SHoI5Fn#O7 zREEuARy6S3bmL;o9*%f ziDCIxtW2;txMd0uU~vEtOiASQbuKgm7VLD_aDosJrlJ7*a|4BUA8Fa%94JYeS3_{^ z44xIi1mn3tszXn`&1B1VFPynQ5L3ZaE1P0)X7>jufVlnChe{;gkz;jhlGK0nh+-+# zc!?vQF$%hHgY#11&tW&)lgw~OA6YPSqr^tc=Nwk#_p{=Ud z4leiP@5=%SvYe3bd{C?r$kkzfu0rOw=@J@`>fYWq{ILcF98v6_SM~}#Vc8H#L3E=3 zjPrbX66fE$2I;>Pw*pKB?t>g+kz=OiB6i83lB}S5U?k_R0+ju*_wPfC#K)}9t+9)7 zM&GS$_HGByF?OxXJpooqPfF@uOYZm$pt%jsA4=be2f{NL8#i)W`s%!WcDlB|oyl?0 zJp^pfr*7I_tv!`5$T`T#1xAimyt3{w@H5aIwyKN2d`xrRBi886j#jUv@(V%`+~5N1 z0%QqnSRED@j?=JX9PVXPEQeY|4YTv5;Y1HRw~r9LGf@e$73<-m#a0u-1Pp$=YCpTE z5{qXQ9I#@7Fm@%P|LvBc$LX1&fE+>5T(9`uSkX6`<1vbm%YcV^ z-K3eNYHE36JS(+L>>lGtt{+`KviSbwa`0q;a4CG)KJ8Dygac;Isb34iNm_r9|BQY*kzrWo*X0;y$VOB2zY0#m*hMX_H(Z?6tGk9HO`pE;HrM4b)-939f-0Y!nojjv7v&VGsDy z&B}PIquJIS@{W>@Ss?oe7>yK_nwUZX%w6SEq=uwplR}NlD(nJ|2-Cw>+{x2t?8Td# zpD^P|#$zB3P;a|g;om+!NLJO(_7w2$-om0L{nZw};b`}`rfI2NH zsJB15z53hmjaBR*ALzLl^dnq6JL!P%4{ih)--3zV(sNTNFsWwkC2IFz4RJ(}$>Iat z4e%11RJP#9?pN|7E4L?|LBCwXc*Qu06bos|a;F$pLg$(R8E$?kYE^#SySxo?)OnNNivN5F z9(cbRww87j|7b3qg(1h?bZa$sm{`4RF8m?Ssld7F20*BXTON8)Z;3_H)dl53d_Mkb z9qj4P(|*(XF|^Qq0>3;bg)w7H9|V|)hgPlF)b4VO)n)sW0x}E_#s}&S_yhOK20Sg) z`&np3>xf?_f>{=LZq0ZUOObs*t47im=H`rOE&q=oXYz`zA)fQm`?srGwE-Aavr_L* z#p+`ZH-}Z=Q`beuqXBV*G&xfr2Eba7)R*~m3q@uUYr3^4z5&Awv4O;L1H(i6n(*eA zh}qnz+#cr zH}7Se__|reRV{wgsvU@_uQ>f7{PE*+JXn1Cf#`ea)wBeBVE;4?cG!^_Z+h7Nkayyg zz3Q1SL=hf#T8YZ_+8VcxWPM?67Hox413)9&I;`#DL^RA!Yw5*ll#PBR>fIz^L3&DE z>WTI0Vl{DYhW&KN-+0@6&s8l!XZ4`Te&C0|>6ZCaK!Iat7jt%Jt60PIqJuN|@D-VE z0}a!m$A{&Y{Lb+F)7k|yiuU2inV;PaUnS5HxVHbf_l3r|nf@|{?f_zo+^R?F)8?5ilff|@hu+)dzZ0=3;?dJ3K<)LK{Qd;ueCgT~YRq9b-`+Z{A z7ES7ZYmiH8kVW^N;iaxK7MQ{#S`Z!Z$-l-k4xlO5pF6P!mZwlKw}O%~T>J6kiG^hB z`?aZd_~F~8OiGSPN0q;9sXn+ua zBNp038oU$S{9xPaCu`JzAA$zkxAAXR4#Ngpa@*1sm|EopVhEUMZCzFvAlsiG`)ci) zz|`K1Mw}?n-g&gQ6UOgCLaW1T$Jb0hB#_Za2NL~XoY9r!48i_~1U!sXQECLngdd)H z0%}#O`nKNpnxvxtM#8yOmlBBn&eORMdeR|)ogeVm9tu6ZTVpp%IG^}?Q2G2Ta&nIC z??`mCtB>{pytrun_lEs%$}hk2v}0J9+z&3`K-r-%mtU$T5sJ+khbXA_ zt4UiP#&lm_>Z9E?c6xMtGS{Ck&M*Aj49n0tSe*$^6n$*3`DhkxyvEAOE(nLkykXTp zcytsFJt;#WL+AN9J-a+&yg_PyUY&w`PYU?f0dW(R-8-L;P=+f&T!>Oz5-eRIFe_4P*C4pT!AOTir6ugP%3@{LphRPnkMI?TgDPSpc1@2PIL-oyBrsE3{fKnNTpy zs*K&Sc(B92Yk2O%eum;{Y~`lBns?3BAJRkbf~Foyh`rlfyqW1q%+3FHx1Y;G<`Yi8 z{_9a3hJcJ}&sTkB;XbC`F4;!6Gxn-oImia)9`8`716fx#g zR49rS8WtO}d{3{=iu?E?9}#;i*?XL}vBP;g7-`c4uXUl@&9~DYuX@wkI-UHmhk<&+ z0Ib+iL`?IF)Im2XpeO50&_Z(+6GJiY&HJm}SDmyuYK{A9+Ga4pE6i=?ZIc;Ujicg9Mhuhk$2Vu@X$ix< zB=YR(zupu%dpGhXSb0Fc9!J#A;b|^WREnr)EaCwOA2D-9%gr&opNuw?M{O`2&>UcR zzl=w8KLxj`&Ik$oCPs*2dno`Sy)p{n0D=xS5L6tTpPw%ozWytbU^CW@pJQ$t5!;Gz z{tgz~d)s4hO}w8*FY-UILR+!$U7rDYg9LGfjsIxSDR~aeL6%x4>{O-g^=c# zxT|dH<)#W*0FLbOnJBvSwfs`N`Y@B-nQX~vc7?3eD&>}iJNqTDgJj>yNEo=UeGPmYRe6QBxGjA1_)xo) zOT_)zM;p#Xel$m~BJSqNd;gw(J7g2=Oa4(q)P$LlaO*xi+rS7Yqbw@Gqw@xN!oXdW z1>6VWifYrF+phJ|?1jY|ew#A+is@Hrc1*-47hXXF#Ny2!Sok5&{%9eJ2rv87T#%6l zDF?jP1a7#1sYjbx+j$n>0_+qeFL%6FyvQP_oSl%`@@~pUzH?QR*f?*)xT^uwD~x1& z(zulQDG#vNYRcz3cgvXiX|1X_@dYaLWBM1yRkn0JQvpqFjfsLOY2;$1q`Zo7kT}ar zG^8goQO!B{gc=Vo26Y1zBAFY)m6r_jNRo)6hTOq_nWuYhfF+YBAhB-Oq6o?9w ze5+1Wqli-kbm{zg6UU@vT|R!Ezk9p}#8d|1i*|MdswbAkK?I`L?9Q@tq0&biu^i*?-Td_L1#(w+H{f#9*_Aqnx40wG3n6)|D z;x00gIi4A7YYqkm1IBlYW>rn0ntwm@h7k)eh;Lu#&-6raisSl_PVN|6SjUIUDf8D=7*4$K`4oZcSrdP^k@<{<$}uQIS1yFaR)kvd^bj9d=J z0@fqEhK^Nvb{6XQ_VRv0OxWBcy|7h?j^8xAs?Gv75i2PlM%5m}?I8;K6SO~gVh%Vq z3rA2QO@fJb!vfEp=5)&N=LUI*9^CC0{rZD6wL_trY1VH6`VTUcker=YmkDG2c{G^g zXuw@h(0*NbNpWrlp5Or+RQBE6Y-64hAY_-ZdYcH0BU|3;{c7^CHT=UoEEJmS%g>`H zU-soszmY7wBK24EZNM7Zz;~Q+GxgJa(EP)R9ws?cfV%w#8Xo?N2V+`|nDlYLQF7~L znHG`z;etSBtsKAC`6L(Z)*(f|Lv{`V{EPs`?GQzm3mK3!9}2<=_6+`oF;MV10#)f! zQpMaE(@mmAYiV8YSuhtMaS{2c?!QNO;5+#|H_+r1O9sv&Y}xAf+bd}(rG@P19aPz} ziJ=m5W1=7^ggBsk{Y*K6e}RpyWkvIcKqg{>{<%#^R4r0MfSEpQ1V#u-;_owjkaE_C zZ4r4G%+ZQT1+nf|JBRXbs~|bpPSnsd^Ho0H1_&c($HDhj+fd@PXWOxI;TRJ=pc;Qk>gE}z zAI#pKy}kNpz{)eq&}%^@o7hM2m>MqCxJG4v$(F*#KvnmERu?!qu<=%*!kehhuCC=? zy4wIaOemI$1!S#=4u=_~h1gR>g>07z>yiML2n&=M?y@T&@JrR0Trr^vF$Gn5Rpz%V z((?`Aobaf|GYLn1_xolT@oYmaqQKI{obML2(7!bj@tp-&be9{@NoM{n_!qin2Ppy4 zXgNZ8zmZy6s3=o*sgbcM9{hNT?z9aw=dxcOZpMHN8!Xk?I`H*rb@bvlTD4b~;6j(R zGfzd00;%cBK^9vu@5OVr+~f7c*smY2ZqqaOd?6^IX0KLvXt=%fZ(@QLrhoRY-p)f0 z3@C%N4VMDUQmKEiT!)1Lw3f%Es8l=o&t^;*oqqg)cPT%?{P@&+Nx38Ua=TwSkX|)G zPk%52oA*Unr@{Cl9vIF1gRmYUuyUeyYdFdYd^ida?YwKg>4aA29cuJGV)r(UHPG;$ zG%z4=w1yVsz_XmV`&IYvF;Xu;5QI@xKoBgtk=A=jlV^5zR)y3`g9mkhn8BzhJoG~D z1@xe!MyMWLHwBg~UrY#r{eI`}MDN&JcxVJ)y(=#Cv}T-Prwc<%n!thuP}>Q>Mm z!QLc+6;QRcfcr0bgpvgMYV?`3UR+>#YA!d!J;1*DhY6zRtv&mAwV}Q_miH6!ic>7f zq1o{f%Zj#NVBIlnI%PhI&=Yd3L(??yO^wvay?N(Ri&!wHLf;t%COWS~VoJ5u=B^`I z@(+Ri+z_WnvS(veN`E{UyKOQ$dW`V__C-qVIUlM*@X2NidUnkvtF%r44tkO(&Nx zIcI%mPU*G!p=UEgWryGx1Utq-P{n}Ei6_RFM}3w8$^bPbiYT%)>k1IcO;TbBEdc)* zK*uPjlZ!OLXRLaH)0p8AHY3auLJKfB9g+5TQ$3lDfwyH67^^+S;Q2Jr8nOG6W06>!HVht($pKC^IIL(WMr}P9f68K%~A}6ktueA z{$Cxt7Tu^U9>wMjn#=)xp8`{R-=y3BlQH6nYI&W~WSH>x6k1Dp6q(DV53pfaR|__rKTK0$1iVxk@m2U zr82lmKYVLww41D=l9E8rVtDbj+a#EQCUCUFp&-p<##=VsYq-vb1xZgH>UC3?9M~183vwd-DIN@jhX%xuWZc_b1A)sF{m6gtb~c}-wqZ(+HOjE3fH-% zeKdH{47|1&UUHg(DGyU)X%oYlWyw#H(V9_s1dS9_bJa&sr#{+^(el3rdb{AiLX*To zN=dDDM9<<-YP2v_7QP(7<5yoEm$ICpNF?iD0E+T09={J@B_@Hsfwiaf?6vt_FVVXX zFmeevi~$FwCcW%$aA;c(*rPNMt?!q48#M>NIe1<9-^pi@g^*K3zMQQi$bIpbVA^b6(j>N9&oF5kUx^qb_CBV2j8rc5J?qP=%7hWo{9szKhW3TLb+K zV#&Dx{r3JHui5|X^agNhS&?NLOr3+LHzV$Z^&inrVkJKVfe-Qsw>;{@Ea;-0-y2+` z-h7#70Wq?uJ)o^imv*_o0lc#TUK0dpiBiWYSZcc{Ow5-7)p&{Q9#5o;_E}RdrR zT}NHApGYm7XRRfG^PnkAtz`>5^SGj1>oQv3$bj}CSNYn)q8~srRR9Y&i>j^X`0z=oAClfq+ezulO11$eDrxI1q-fM_7@7D z7u_RQ)1J5VCVEEf$l;wj@T*;2#{r91|}mUjFO2XL%^^E`zl*DeP;(`mpqi zb%IIF5bO$86g_zySkILWP}#ZNdzR0f)vW*7+iM>0+w#J7?vJk&_-yJDZFP@`(3RqR zq?>;L_UJz+U&rm&-AZ5hu~U;Pa<2x)_USpz@Y6?c{QV9RZMk1fQsRP& zYw}*{zAHjyzFOmzS0t01{3y^M%O=yYQ1A7t!Xl4bTenr0H@|NV+3;B;pXmE(Pvk|3 z@6TR*kv=DJB(mDcsf;#!F|7I?PI{qUb+}s;A8Gx`a;V#_XNFus_9v3N-RwRFat8V- zKz>u;swcY}oe3_Vrl*69D|WW+8z^A+QJ@)?I-C&p>dc$}IscQX-QZsuuG|#=Hz8ON zEslJy*_M;}wf5I8G9AL_P;dj%qGW{~lrdR) zf%>eE09%ht4;;9X_4@OpdcsdF(Va;Yl6rLoIuZWx1&=Cwq+57DiqEKifc)GdpM_Ni z6HfwOTsda^#fanl4)hpL!?TSIoA;(vnS5VWG<3T{G8?#^XBt^Ct!~@!n$(p}yYxND z%}ZEdG&8uL)(sebMc-sY0eI6IVFxh<3jZs_y4M=q1NL7&X)Nzl@5D2m!3%1^*{#e{ zM%YdRp;0dgbraV^4+Rg7(sUA4l+CCQt+niD(qi{hXqLqKK8c4y>K+~!;}owy!+4)l z8~(1$5~x8d4yOYy7t+n=M_)P9+k9GkQpxFy`>F{9qIa#_MEb~SCgA%KtM898sq-NM zo#iQ3Av8k-PQPNd^s|%KlZBZ9QW=s7*}_JkC7*jF)!qF-C|2YGNB)KLDmFiMy8bx# z`ZtRN!T1DxL-siKd@=1U1`}}=ED!%)K`x3d{e{va;i;Cz%FmVEtj-PZQ@mh(Rl0hW zhC{c|Gd9VWOM&EfqGMi11*n}2+_K>rEAsnzHgTQL;%g?Rny`Tyz^=HAe< zNIkhur{iybjr?9a5FZg%-SGId>ozUMW3zGA-0H2rvK^(G$W=jGh<=5bIZc7OOn96U9)k73BdZe=5)||)LTr$XdYtKO7wc6 z60?e%QkJJ_~V4-ij*}vtC-7wlh5;%8G(a;-Hi5q z>yA4cayEqN?S9q6R?6puxkDr0wyvU%AB{9>OQ-56^^r)O)@pn~AH14O`qb^+Th&{I zh`vOm6xCPf2$Gp`03YMA!&@%Z-BTI3nX4R5Pt2F<|6MC8%z@_7G&?dqbU`+}VzMZf(nR}cEZpfq-qe36 z!sl_RinUv*t4hAB^re251QoYiQ0d8)J60>F&-cA>R^kyejfN0g&HoBts~goqAk^A| zn?j10r+OaB`lE7r%|eke(q*!jj!mC(o8kCgS2MUf;==T1Q5Kc_dNUbZYbUUB{`?CU zEEk?XxyFlN{S62DSNLLf6IH<)w=FSde`!6HP1AH6*_D&P0wS?PbC;Fe-Cn;4P@5?xG3nOA%t(#j{O zklxUo1D2)oymWNxw$r@a9YVLO(6TZzGY5{WRnxlJivN6o^%dg;xczkA02SgxU%hgk zTpM_0*U4Rsxg?MeT?YQ<35GKewK%$che&GQ72?hUon>r)1X4shY|O4=D#`y3%3NPR zer8m^=GT|VwyfN`E5-}nTRyRdE0;%T)PDVG!T|g%Ihwe0@@M6lsw$_;3EuRdW+>P1 zi(mb>UccAmZx#`%5*2_8T~oo9w1f|#X)bq7hpT@una19g(6S@o&vi20E|Aqa?y_?) z=+_7j?$wZVl{m*pGXV`P1sE{=3?I9WKlk5OFxV<}DNa$L-giQI8rQ;^wA4e!TJ+;| zk$#jE8gFB|wnaX>#J9dqi}PHp9uy2M0Be}79R%UaVKZr13);UZwzyhq!y!2swg!EK z#{8SH#!JVeiCY!}-ud71)FJO%ZrM8UA2FaYor=0n!gE`psLMSSn)CSrSkYn=O}v`c=GDXBHo-goZLh#;{G%sR=-5M3Ne5D`qYFSFJKd|Y^?cpM{m;(d zy5t~up=On)t?~@cAnc*fGUWkFBk6|wVr&ys;Gvmru(ge%>{UzsXjVN5nW;o+S!g3K&RHfGlH> zNp8f6r+jZGg3lN4`!2J9`n0aLRy(OeW{G@k8+FqGiuL5Nj@bS-{F1+Z^8SH%`-?G7 z7eiAGSVX6L1;H~YO#yG{^RB7>s0KWD=d+C_Uo_9L+~!>gzb9*ihTdcUK`H9*+?iVu z@6i`ECIzDaUK7KH_CDFjIs@Y^}gp8h&& z{(@83!P31f*L2ItU4d1)1IjJGg`Ag)!O{iDPfT9}gk5CPvY3-+O zn(W>jQ!(=DxxDQb=fW4i-MSOJSbT^8srELAdlg)kn_Fej9^UL1E>J&-YQ6=0Yfd zP=aF;mY*5O)4Ka~3a#^IhM!uv;1kqCQX9b zoA25+6)q{H@=WrXBvTVh!FA}pPdB{wIUt4_ut$1I1@8VX+U7q-`#oq~JO05rwA}a3 zwq>nWQm)nDD^m8p#P|=&=R`DiF{xy*9G|-&)`AQ!_EA4(m=~=t)Voy;w-*)u@xBGIJQg5Q$Mr%XGIRsj@k{0KyFjyp5T{Mr~9^Y5>Yr4su7$KGe4RAO*vwSQx7ivyRwzU(Sx$jY0h)M&8`3#}to(x%u>| zZ2_tefhsEPO`Sz5$Yty?bJt_WlWX6BCHKoimen20LFKMB1xbdH#fayJEY6tR{&_#o zhFUH&r~dU7%4#1uQsH9RB!zIwbB@E;@!UmLg$Gdwxr21K;T_jSOAgN$eis?LHNoRX z;lWqjRDgsX*u5w0>)*VP2uw-SiTKuY$I!u`z@db#-~3Pq;b&Xd(H~oh*gxe@O;3D` zH>yy+?oiEWrNX)VdYfKZ36RK_o-+4mTN`W{qBF&Oj%_vIOMdcQp1I~;v6v>*UosG$ z{gqro09gWYd9rb!JyV^3Vw3zCPjZk-b%jmsvudJ+=%Ncqb+YB$B25BcGv{7mBv$SQ zXZ`hO{OI7vxMH5kbwZt6PON9P)pUiK|6{KX|Pu(q9 zZZW>8@BHBvO+a=IWfSx%B4En-Y1he*)OPAWeirOl;6-519w>L%6^c!~z>%ZE@$%kj z9*NW)Q&xZq!!O;M$8RyQmjYU-J2r0$iWSj#9nn;bEr;QJjbj`py| zULZx4$LQ{;hC^EoDRU;fTs*}y?EZgE@b>$%=gd;>cGSPX3U+_D$~FAT{O_d zS%}O$_ik&G4c>(M{kCKw$2i5_x8NH&rtd0sleIm*zTSJ}g4wpV3BK|4y^<_;3u~#! zN1Xn?_kcZcsy8aCVNy%uzzgvkt4d4wc^VENJGK0x9jWAUAnHkz=&G@Ke#Q5!Lo-uuT%HV~a!BobKplH#=uwt-)XzCUjxfr4@=p^uY{kOIS zby}Oy02~?%Gx;~soaTdPU@!99jYJEci%tlV!g%XkOgL8EeFXtq6-(xYJIh^`wxt@0 zKfvB2aA8*?@oXTA_DuZ-G325HTJMBO*|%F__Pr%TZ?b*Im7%qi$efFIPu0awLu%U50fL71~8+h|LK|F}h6 zp>m@Apu#VfSscKJd&qh0>iJs9)1exBcTGKuYLInP+P_DCk3JKdT<;9(jl1s3>l;Me z`b`QQx&*KbA9Q3XCYE^F9sj&b?u#YpHPQPicP_zs((9-Ol)&s(wcmT6Y?EL8IJ6+J zCiDLFg(3YnP3;iJecSsD@qTA6#>#*3Geyi4mOU!_XD3}$D^430+ZQhPXn}XX9{pnd z>4E#4(CLvY+f!A$tDd)m*@Aj?Vkf9dj>Pz=Gla47%>tol^-N2U4}{|Uc%b*OtLK4J zlyabATd!ta-?n}DieM}3;+MX8RRKat&9mO=<6DB5uZjMzO=(eP*&GMiqaWka_xN5& z8t1!uh&kX6s(Lxouby1; z?7dgs>t1WWiwaeLM1fSeIWobCno}cVW95U^I9f902@qBe<(Pbt`MhtlmVKk8j=RhC zad1VE)8F_T3jUUQ=@6P;?Zz)TQxwQ6xI7A+W-)JH{{Bz?sbB%V%;Q|q+0jwG%WyrR ztPqfw2!eSb%}CZn_yqO1&fgnNxk>0Php&rw`63eFAb+eM!W0&6Eu5?f+7A%0Q!{Gh zA0(BxuegA4EDh88gA7wi88u%iT_xM`H`O7Y-@yz89FZY_Co}oCJAaNdmtOjet=~lT z0pGAfZcAQMTSSI%T`#H^&G9ws?CD7$YLa01CfXo+vM$K12&qL3w{`GVE+BKdzNbX? zhWDNT39%PIFh%xJ@y@#qstLzWAo11T?`|>%{RE`f7k(LT^9nT zCGJS&+e%_2dV$zyD$sb4KTi6R^{AvIvqQi0p4WMdgE6Q-;2Y%-}k}F$GAqhw~D{8oTpeA*s_9Qh2{myAImhc;ya-t z`&(npNK)R@-}oB%{(E#ZTj3oAXU17^tE1IuGC%*rG0i-E0yC%zL8q5dua~*8gA)7% z0$VUVlQb33w{6CTs&pUym+kez63*~Vak{;vyII)m^HR}*h%Z*_9FZkF2 z1g?YJf&6Q&Qzio8w)jGk$HPP^NurNV=$oigsI>8acS;qF^x;I2MkUHJst~*1R+#<= z?x6wK!Ij57nWH@N0@798utJOZO$8J4VQH0$otzLRC zzoJZPJaSs0mFZ>inZ!i38DSiy20TU_6eE_B+363=<=Gw+PW_$N9VMj`<-@@sfr_)X zHp-5!e_8*R)$vCrQf)~O1y+Yb<%G=EMp*c}@OLLV#!&%&3B@^n`9;u}1!1PPnBnrU z*R5Z}H^$9b^8SihI1-UPT*5)p^vp}Lk@COTNaYCwt{$5<#x&@-^uvlA=BMiI-+y$x zCo%sqErHTmjOYI;d^AmiU4icd`k?AidA$8hh10Lc!^Tfu3PC@i{SM>ZH_m=kLKRvk zZRn<#xIAZm)5mA;Ntm0L<^SXpDenn^)m2utqCiUA95LfWoxZIqDEW_k6lW+_Ramb9 zx4!HtP-`tEex0Y>tGK>&szPD8T4pt-!oT9b!p3_UPP?ML<)Xy&Qg&n<_)hBYsWjh7 z#z~Zp6#)h(vIdTQ>ueb?y~1j6ug$fZ3H!<`@0H?Ka!uOld+7i3+#vu%n-Nv-JFnn+ zgi340o%VkcjQ1A+eH#Lx{Utkai;`)}qaj!Ibv-%Q^2ht`nhN7;Zp;5n79$w%K$oxR z@4DD9@eb64HwE0druYU67;wabi!|Yx0T(%Vi_m*QF*yk2%l*6Bx!eEGvZXgjyLzoV zN5{%Cwh?b>lM{-_Gs-g_-=;q@5SX=>IZPYKH{KW9Zk_w-yU;LYyPsHYGT9tQ4g$GW zl2<3^aqwT&WtD&8Jh=D8goxc!8^4)i-2sSXw}9yoar#w6B0E2)wjAwl(56FwnsD}R z4QHB!{IX%>$ymOF%aBG9nigwG{SyGZ0v(bP6dbsJiSIwjp$fco9D%70K-!eHB;Nh# z&;CDP+adRcd~*u2IFnYQ*yVhDd{B`RNrrE+gCq|1fw8=@3?swXLoC9H5@6A}H_&J` zHN1^wI@LiT7Q(-Ryfx#OyJwm1C;FY23I$;PMM7u8s4F>&~ zUhbUPEiiSlWzTr+H-%+7QdNVS-M%ceNc+1BraFS~2l$d>t=DkY7Ec~q>3@U2@kVJ^ zz4Zbo!)+ITCV-KrJz3l$tup4oa#*Epfqp@dX}fR#&$g+l-HQOtx4ypJ9(&zZHz`6Q zRJP-Pj?G9#VH&T?jF{tJyjEq-J+&`oMWo$-k68_d68BxQm*lHKKHLkt%S5BUQ^Q_q zM{1j-d_i3Q(;imSrRB$zUA@p>vixjz?1!-LWxGGkA ziS#GDHMhKV>O<@=vipu6_fatmgCvai8?BwXUEqJ-mL0E zQ%1d#O}RzvZp0)Mk#ytB3Amo33)MU2aC9zJmrT9!XnN-UMV?*LI-+4V0@hN9G?y5| z)IPN@VWi-WrOmi|CFB{Q^|PS;EFzoG?D>JWX&Hil$_9+J+BSOoeq7((ob&3dE-V%z z6o#9Iz?x#X0~z*~c9#G2RC60RFO-)0wse)zFPCK&9)BA$pJuCoiQMmE#u@M7C#v1} zpWkccV;?m7-~=I5dhXoOgN){-q?2{684_?v$dQh75t1a zK<+kX2)^lU?_Fj4*m-%>sFK^*;YgtpNQ}%HPj*HkpMbT@$8_h8xNA|prqR&Jd z<>5q8o%q8KXQAWK@#dDzYcdJm`?)yygLH9swHN@!3?BkFkxUaB+!afNknmpUV_(;c zr+t2Iqc3eoQ83v|3(=5$CfDr8vtI*eL8MwNR$up*t#2o^QB$TszLgzm29AZ?vS3Er z#qC6f#~Xnb?fEl*Z8GmMWGnp@T06YPg@R~F{cVGg}eoV zk#i>TrCPsTZkT5-rB&qOu2}W4IhWoBumi{4CXqmP*&mC~rIX4NpmpVMy5XjRcQy}A>zwP7Jjmazy~9G3XM#vooDD` z)cW@wXk~{ZrT>aNN5Zk|-AQr=g+zVNMp~1Ock;4|D>}nONQEGE7Z1sY}L3gN*n3qk*QFlx+DK{a(rwBVN_&_Rsxz-}XwZ z6gy~M=^@M9j7A{8H!hTzZW$Dp8vzJ?HRPlX#dCYcRpsX;` zJ>WRvBdbC81RCtR>@qnhDNG|D5r`zn;?tbOp}?oWRPWVVx11RhPX@4j-E>+q1*nyj zQm1Z|Mg$dF7;RL#)jxB|F!mbg?k!JCdrJr__bjR}dLNQ!HPKP>XGSHSHuKkCGHX1- z6BQ<#Lc_GQSXjcj5zqSg{0neRhWQZ+{>8Nboo5habVbp^1R!>JkGQUa$@fK~Q zVm2FPf^h)<@%B<+Kw33aaP;`U^tMf)!L$fKRi=$(yM4``+-N9&GiRaF&oyqZE8~@{ zm$f>m_T_lP(KoVT4k28ySmu^+tX}gSB2QuzNUImRs52iHWc>erF$4wRY@<0@ zlT({BF9reMs$|AK=fy|$v|Fa+%;6Ifn%f>_3;ouq_rK2kp0)|tfBiboFE{6{7OWxu zLVU46O`us2v$b;WdCqw;8HrkKI?WM;jP6q1TBng=3_@o{bzCQv)nPkJ`}49&E|qZI z5fQZzq)XB+WO;(j!SfRIAlOv#o@0O#-wlZk34-w&G@-Ef<#?fChVmUT;}6y1`Hs4c zqITwPUIK!4FU%Av>HxOysO&Ua(_iS?ligbBd^j@#{er+-cUl`Sof8+dDH9WV)83`M zdwjJ1$ooJm>+XtVoAP~gj+sm}ldWag8yPmPyCWAz)bgBQya9bXdHdl(tT&AT#Xmq2 zu&mqFo@xaw6i8#P=m< z;86rBlYOIjRj2!M#cNE;K8Uh8l#~Ip^|kJaBubJM9xdpwI%OBwZJS_Xv>DnfuDXrz zAG&DU-kZw}#eyn+e}1%3cdH$*2Ih_kFa3z>&{3#kQoT)F^7Ce8KCR0#<;l=3Z6HIz z)h{!t3@fPY`aTIIIdsV10o}`+H6de>`k9>YDL~(dm9kg1v9}HUyuSdDRlQ{{*xJLw zy1Jd7x{A`xuFlfjB(84H)@`S%BV8D|geU`e^j-Dk9&2Lc)cYvkjSJ&K<`JwX-$fwt z2qY!7Tn5&Fr!G1$lrP%qzQNyeE{vC_S6z|*?l-ix;c7f&W*!y^%eii2^aWrKi)tX( z;#085XCEtkqIZEtBlHu-#2<>zG%IngYg(nwc(DHU+H~LpNG$^!1D9(9hXLzrc|}S> zob7hJ#*=p;CQ#(<3 zmUKBTBF7mdug%C}M%LEsIs|w1Axh-2v&BG-G90A8s+qV&&mQL`=e*fpD4A0kdbgGH zA^SywI=tY4q|id?PHS(jou+BbAZAzvGX-izsjS^uI>c^+9;d}c3r$*t^97T!5eNV# zK`Fc`ItUmZ=9ex-y-@*w8&+lrjh{PZR=YJ?V}~)a*Y~LPGM?$=oP0{$d~T!D>1;GF zuXT4Obc+H)nL)25=a;LK_aSR=-wbj5Cjof9>dp0_nC7=ZTRL}h+u zU3~m~Syb-i3d!gCllNgqTmWIP1rb>r!qX<=T2Sdf!4RWBC+Oo@Tc6;Q?jr`56LOd@ z?5SBn1-h`@&|9o{DCmglEFLuh3^;$rj(&dmwA_!mQAV5?0XmzEiq_+eL#M zR^;4!etSJ!xXYMX=;RYlg9zb+x8ujCr>>_bmg$a34ZPuVkIYnm^NEL^WN&0{#Q_KeBu3V@^ zu~qU&PJx4L1B`>5$R(o-mU?9s3Hi(SmYNC zH&7%`34)u^AdD8@<=mOix}8^$CJ92G$@e%$z+VF8e1m|^3NB)~_tHazYY^vvI7nJh zZbXL@l{{b(W|MmLFY;YyHc)$jQ0ut2eKnZ}j|@Y^#?G#;2}#khEtU(^ThYj{-5*l5 z@tRF_M3# z=f#te1W;Z8&XGQF110th)C`v?7u~%;sgSizp1PjRvH^Vw0TP3-x<7?n&v`J_AUUO@ z`gR8{ZLNMqmsbUjzBJV=W^Foq#gT;lXxUh4ID;A!1yc6~`ZcM@2b&>VyyL_fa0e_8 zFd#MZVXi$9ozF{?33gEUNa1v)zgmHyJ0F>mvoB*);b*%BsL&7}xL+^c_}8OS8d&CP z#mVSj522-|sE)ou)!lU(*^zI$LlR!|5r3&3GgN7WOQX(hqCEE@QXil5WU~ajDP6*o zOK>?}hdq~`npbs%c^T$jAlDAh;DUWBv)#b^PT?2r=!S*+;n$f4Lsb+d( zOA{EVzAe!rpEV!^Ifr)G(wV8;4r}T*dr9oD(s#7c&ty~XGN~r3>6(2{*gCl~Dv|sH zkzCQRpq&{v=i!C+GO6L*N9tuhN7xZIJl%h3iYt{wgHO?v-X!3dVnGTp@Q)x2@RfeN2 zf)+fsw_i)zE`QV7y#o{>j^UetRIkc_O3c8)h}P#)C6H&J78j$Z;@)3mm}}C6qv0(f zj*F6RmN4En_neb+1s!5)3g#T=f~2p?j)VZCy~>*NxWiFY_{3sDyaXUQ250}IiiQFl zaQRgecL<=gYdqZTweGj(=e-X9k){&#^xWi4ldomJ2iqx5KU)!IHz$PImyH`chphkg zaFYR{&27$>Ocby!mvL2YbDF*8@wgZj+Ej}DBB~D`An1`xDC9k$%}SL+JhR<58OAdj zwSHdry!PJGx^I>bkwZY4?QPoB>KuNpIe1janRaaf3%%<2@X$8wF^;|O?L&i zkP6wtR+OI9dpVxhJm|Q9z)^N^h36?s1DDnzx+ZwpEfkP{iiy5U)Jl_6m8^yin1Cz_ zjJG3$m%EJ@;fzEC@r-7jtswVlncSKeKMq{ZAcpP))f(pL;8i_*f^&cM!O8eH-h&bh z05|^qg&E;%eeEMh=QYPCC}lVA$hx0ii>}=KNuEH{EemWukpHQCoD|^b^u3p9Tg1DA zPcFbi01C($I8K2}x;c_>)ko!ZF+$?x-%$Ym1#J|SUIgIcR0${f1-mm3M%VV)dMo|5}#d-1G;a+^jQBf}F z-r%6WqlV`PI?cHF6f?J_TVxlkuIv%9?A5SU*jCuaQ-+j1H(3kBto=t9%?`rTxexaJ z)AoMJ@3-e#q$W?O&PWNG5?N|(JS$P$tFpsn;=TbFPpq@k(F%kxhzdU!QzZiUsH?Fy zj!F^!1i|g-(^6ZX0hW3nvQ4iC=Syv1BD|mYi){KYP*wsHEZWUT|DzvE&7vwsZgx6> zh>{X%A1hNQ8F0%gnD66f`s2Z`FU=2KSt$-cEx|XuH1Vr+`IkCksstdw7LrQ<@`evCFzmn<(c{#vHFj39(fnpm40O9-PMB3pHr9EbSU^gv^zsfJZi)@UQ%ynraCy%=3e#z93Hi5$guV)WJ+=bU zI0wkr()YJoteBzlRKcX3pZsqjq)iQ7T4OL^;PnoXkMQr?9Fp|5+*=5JrBA+3)QapQ z$N%HzdQ(Mf)*_@ zrKq^PU09vN3>wok12H649ts~sUtRNPrh!2zW(aMMEGVkl(ZceZFy@43f$hd8gmphD z%w=1n5j`x1rKlo?GPbyInU?&Bw~FvHRY_x?D8`X+*C8E`saa6u4uaK2poBk5F*UUV*xl&*X)gN_p@GvaRxeDt88+F(;m3no%xx70<0 z?KS#Pf;4;=m9*!FQt=}NJ9Q>iuVrV$e@%1iH8gADi~86*V$Fl<;rd6g}^v|^GR0Z zs0hm{RV*VM%@1N_Lziw7NBjw6F zB=n^`AlxmC$T{-@{fl@jUOw*uyDY6Mt?RK;1F9*6XDZ3$VZKjUXcsdj?T)Jl*Waj1 ztxT`9x=I!6vpATCa=h`-?Ng1BpBr}$jJI?6;^6D6d0Cbw2Y}{|E+7jU{je4|eH1K7 z;}Gmhpw@!FDkfJk<1#ti!9nE8#LApZia|d)@H8XeN4-li)u}FuiLU)0AwtNdx_J!C zGF3wia(i{MrYI}nt(%hr&8Il$^amm6{R7$gwq4)_a0 z4G=hp7)3#ID1#S7WWjvftOK2AD-i;YbR_Ns8ff(CY)?O^AnNK`T(9Z}8YaJ4!qE#C z+lTtepgO3q-$FsA2Kt*!_EU+N!sF+xKx(R@uAm}SF+MF0);`VbV5czs`4NEy?QPoI zC!d!OF{E%tjKZt*4GVCGK_`O+M5eY0ou3C}JdS5bqP@Y>2f*XvZ1emCrl8cpVKdai zx{qYGCp);!XM@c-#^pp*r0EF+(xPV0Q;7KhO%;*At$z=CG%k=a|1%;Xs)9AZP!$W9 zr|d77&-In7^-?dW`5%m5(ix>C(n!6MP_Yydtqe@vYm2qM}Oj*|S{;yF#P&`7BvL+ekha%~y zL}oJ!jE8>iJd@HfoY{Wg85Sc5vl%GkyvuD!l3cZn@O2tV>zri>GrxVjgH!xV44kMR zL_97>n-md8tu;L&bVyK6IC=388FAR9+WE9#Zn>;TxPG-&tl-_<`9YT7R2)S}(2=&C zXWG7N0nkRLW^t5vv!o7O`bV91)x$eKmp;z6n05d4T4%ISE*nb2&Em}uOpyN>I(lRr zC(0N9i81?yzeg5QB_CK_1s#DVdTSsX?zIfn5>QQe8ZTff=A8+n9D=)Y?RK7V-{@}} z`&J*gG$<$amZ2`ax?*sl$I$JfoT!uOpm3E-@>T%Dp$mB`=rH)pOnI0XoUR()0$(4w zYkmPvF%Fgs0tPlgToARx&~S09pa}w1>df)$()QB#tOTh`W?RLL=V!?CQpb?H!mu@$ zn^0BdlaYbG*Sz2)|LT#i0s<+`3T~6XdgRtq<>ABIT+Dgj@~MYOv4yk zBT)7;&LBkw%Dx$<#x>50&3rG_;m%Xdp@1X+r~!o+2w{3cwl7YtPSptZ=(lJn-wttY z8Jo(AJTfvM{Vk;A2`=DRI=bH&8^<5T_2%`bs0Vu1Xo z*W%!$kTW)TJiIYJzy4HmvAyyGKUo6Ymr<5YS*0>Q+lgs=exM3Q5`?geS$a_G+8&@i zXPEvAF0Fy@r8C@Z*hS7}u;YT{y)3>GFFCN57z>bxRoa|=bSkw3w*Bya4$jbuX zXI3!JDP4W!@NSYIhi`N4$&o9c75XUj@z|(ABV-PwG7m^4U`-aZ?mh;cWgV$v1;Y_9 zOj3#nn-loOAwbH_P!4jEKoeXO9s#0N?LD_4JKus=Cyf3N?~plR@4io)%RrYVeNVv> zWh!IK&YXBcJF)^*abVC)EMZi=UF7y>{h~+8fv6ORD zwI4qeLn^RJN#xYf!cPe}bnRe-AZ8NC+RuT$Sx*$eXw%zsGHR5Xj{+P(B)`sA7oTPb zLS*+_cvXg9{^8`jgMRm9bulFFNad_R=Owtk(?L678!qdCvdd2*mGgV@dhYupiD^ZC zJs}-k74{Q?@IKd5;c^M)D=UlQ7_(>ms+KkTDbCo1`W-bxBak z4|rWF6TBh2{L8bZWF1OL zJCuQd==7-->^Dek$w8FUsN!(WzoOqse^T;Dvj9Qq9^mKJYcH-@gTR6)0R52@g-Kt* zzmcz;A9`$3j7EeHeEgCHxDc_T+SO#Y_oLONW5Sc?z0C1@!l|~tN_p!*i3ZS!z#dqx zO&$Wan&g3^1X`dFAKM=E{f+v%LN31OzL715w{`-W%@PXnmi&TT7q3Z15c20nN zB%9{t>0chKKdCr!kzNZ`{7}^gMBmVk0N4g-BAqZ*Bd@M}6vq6bVw8vfI2vrpIgH zAgpS^rbfx$(Pyg~hAx`bVixbeF+xrx38^t^KPQ`UK&uu(=;=AoL3Cf_?8J0W_s9Fd%!f68mIaENJRc;oQ43 zt(Z)I2Tx|C1CVzj&eqCb7CA~2A&<@LdIT=u7_HU`I*yJpFaSyd>Fe<&ZxU+)jX0#9 z-`ZoynL%ZAkb2UK^^ET=f((hzZ-|Z%rum@5({XNAHjh;NP~h2`rS%l1wNXbK106d! zk$q2YKZuYJ>O9_^^r-`H?T~w+7<3&&jA)uLa=NgMHydxdOh;P9ed&MU#Ep_=lBBlb~iW-4JEz#Q`8Hlpqk4FXZ@w}TxI#i$3pMQqnT{wif@rvfI&Z#%}1*jkGx z48UJcL2rDv_ICg_gA#!rTsm4jYh5s<$DZLZVv}AV`018=YdYi13uzA~UM|$9mhqVK zMwN($No=1CM;?fH3I|{ZO#kIVp87jK(rC3<>9sZ@HX;h`MskMtvY`s{+NKLdIu2t3 zV}VS5XhHb9WW1{3 ztLNAsSd_=Rrs@fAbFafd6^Od-tAu>HUUK)rl8Ttci@&L_PflJXTu!quwP{|=(vKdB zjtxBiJ&jT%At#D@g44}Y18Dg3n??^5EVZIlID`rUPaU6xbO^LjFGAQ=J>j~=>w9j& zdo&C?8)YOPCax{*=~Yoem55iiA$+~V_c9?g`UiVnjDbThGcv0 zeHXxtBQjLRuzLmZwLJh_85#=Lbyp}%cI-2=pDae~P2RViq?$Wwj`<*Z(8HTsF`*m= zR$cmjW>*vRo@Q(QTmax=Ki~r!XO)U$gzbDuQ~rqG zv*EDe0KKP?9<68sF|N7_pn5XJ32>7tjZ!KgE|eb}4cK@k`~504KwVU1M!z`Udda8y6|uja zTulR@2uXdJ;ere<5FL^OC+KHBwG@3$C{vHzzH06Em7ZL)*|wEzsah@XLo>5gh@87T zo+I%|WEumKlWz=2qWBlSlEi(x{kvFDPxGKmFAVbrV*x?|N%ywtKP!xo@4=+_=fu*Qnw0{#OruzFV zwamybrlsJh4wcoCJXa->V;UpyXJb%*aTzV_6?Z&onAcDI2%!m;{ug;v{4dpZ2i&U2 z2a4=yxcALtFl~V|D2`T@8WYo&vk-cknkCwusY_w+`Gd9fqvLV%PQa^b2bc0K`SNZN zgq})Dcnb{n#$Ahwt=PPXU>usoF-n$pQx7(fZ)q#B-IxaPO-o5B=nd6^aS+c4f38$6 zLeQmYEoN>7BWnd~2z4`8@%jV+FmT~x5sKWC@z%#T=8tru-D(QpboQix)HMiQzZ#qA zq7-=MM07;M)A6wy#SX2XEQlz^-aJuu|zamBEVqpZk&rz;H;)B^*I<*W1-cENyX zR$EJHXnMY|>ZECvBMrIDi7GqUq_hxBkCibn#^0ee5zFH{;JJRtM)*TCvO&M~Rw!~4 zjU=PjBLTWk0;{|X{0K3skTYI4H+b=IGf+{f3SY`wsC zLh)L`wIuZa>DUA*8k-QV;WdvC&y{?gS7g{M&I!zzl3o{p%`wb{RPUgp`CQURt1A;K zY2l>SBWpiY!l32_LfX4>Acv=;y4;`#i$N-_M+zLKCm`DH!dY$fLY>Zf5g}R%rtfC+*rH&VehNaE~K?`VBCt$ zPOC7674$tu31$jjfkQpWQ;Ci@Ar@Ucjc`r&P$(!3I&Z1m)#mw@41yF0HtFpsxkS;N zOln`T^aVdG)um;tNARRkMfCza<@F!4vK(U$CO3_xt+_Ku%adp@t9a{iHeXaL4X(B2y;mIdYU zSmDj85>>eWCYd$XHGoIh_8mu?5|g0#?-uda-H*bJ^eUrIv3W;I1Z1GAag4^EX)c%4 zzrFy1zX<*b#nu;KUL4>yfU=6cl{O`W$V0vl693{_h->FowbueQbG}}c6Pb$EFL$@I zHb2rPjuK?@`=}(xcmKXy1Rht(8;@I=Q&M{=pcc`Mz*bO=MtY#7%K_QUMG>@+><#6# zrBwz(K)S{k^xIRbqsYm+-_uqhfU*W;3?gnrd^;(>b*@QG>yuZykxtaT5o+nlTO?`b zWCsWsXsVe{uX)h#tT1|VxKUrB!xe&=$`(O`rU2BSRvJM znLc{Z>U0*Ghf~N25$+84zQx>JD{ES=n&ZpbJkAA>>B0_!;eju!c!0+(J>%UruD2eU z6H>);%mqHEPu4;;FWR@gL}6fD)SkysiC5AIYA$I7Gn(N87KL z@XW9&rg}}*Iirvfo6$>)5`g(*4{uH=Bqr$`Tw_UtEo>$3;Y$K11MCMuiMVPr!vCQ8 zNz6q-G=i93Jp$UC*$9{6z8a{j@b6Az4h(;kZ+@}6vvqgL_wIaF2(&fa9i5URhtGz! z=ScmPncpP*^e&3+;z!u@4hVdsgV4*IC?w76SQ~%VN7#ig1DGSlT{ayRG+6_1hTz)E zv_TuOBq_3jOY1opMVp~d*CD*k)jHxWIQ?R72$?>>Z85toliJQey^byk?9=;=)5Ptz zDb<;VCn>wMSg8Lv0RdPKMpXw;6`+D^?HcK zP{##8uB!zD68Yjquc~OBZJ>ea#?zXB8+Kf6NxrClh5pa}%wo6R@wzbhbE+*5h3Q>6 zF6lpR!2Exc9ci(H8;xE!9eqXj^2(6~Mg9u_GTxO@uhO%c%vWETC8zs<2~!juafJnY zr0_1wk(L+(8eoKIxf6-zhKT#j*5t&}qOFxL&W=4GgaHp22+@DdrMx0byUR`z|CCNd zzq$f=_{{PLKTsA4UA+7O7=gTzhB@w64;OmC zx8g&C$aQx*xr+7f6+@B%AVNGC!{1P}cu>O9sbI7#o8~tOmA{&Gsp78ngJX|pq=Bf~ zwWyWDKrv(AnNC#Hf>2(Fm2rW`D~%JI4E5#n(ZGs|Z9+k*j^>6a-HnZ>8&3C~~?X86Pl?~^<`E&Vt)TUYjTWe|d0>kn4Ck{=)a)go-)pNJ# z({vvQVs4TEn3{Jx#%X0!TjD5hla>Wr<+uq)gR!3g4C}PI|6}%Ur};=bp~M8IN+@80 z$U?M6P#Em9RS5Ll#$-}ovW7tjIb?nz9IvIc+wA0WQ3AP=sE0pq1%~NjsPTAj<+F1)hMzZBm)u4*_}qr zV=X2iQ;;VBgyU$I>)qG4@9q3Gt5^eyn6!%C&-Te0X%IWm!U9N73g0Q;fz>-06m{z& zn3wug=q|D*&^<1O8uZF<{`4`2=y;H=0~pO0VHm_+1JpUbsVcASIGEHBE!nZgr#vGt zjluYJeMk@U)du@|OYE)%DpoY{?`$5~J~}>eB&40t0IbFB*=m8wberZrDTRmNK^+fg zbGg-5&wel11OSte4p8a}OP}VdO}o`>kCT>PFtw{6H2-|G5d0AdgfkcrtDI?JjR{ur zL+%%^97bcR3DAa$!DO#B3>as5*{tKUiM%l%G<_E;I6_enpwj^8Anh^@jE1(m7EqHk z^Qm<7x+eR-dAJQ7eJZHb8d7Oav=f1K#+h*4=&rrAC1ce%z8^ql1pN%szO=Ba6;vB} zUASa89wofcY~;^uc914l%uVwndM3g%0|2+`U;7*EY|+lP9m?` zdJ3#UkoWEZ{!9+okXw7<*^3(gJ(8?%(Ves6&F{GwpkJ((E^dL8r=M~%Y&cGV|u9{9dFFd%)bB*P=q(C zt#pmuOU_pxv?@aB+E3pdTh|=^vYe=MAqRzS)m}s5lguDcPo?I;!4E<_#hW%nwXo z;%ktSX$h9hcQeHKwP3sJz+=6FE;s=hfZ-@?pi2bH9j|uevU<0~fAeQA-%jV2` z3e~Q}*m)?(M^wG9)Z(LpF28>a;72NTyrfvQ8J>nGCd^zo$5#m6dbI|=0t_DH?W|@7 zb3}Z7Lg)BdiotLi9t6}>kmvCEr^U|_ZqOK=@qSCn`;`BnIMmn#n6Kd#7;HL)y5_JK zdpXos9#4i*i+IbxKxjPlhSSL$b)$EX?C0PZweNcUeS<2#E1Y@8+Y#<5rF_8oX=sAl zh4^C^>$?E!d7vQn;AR>9s&n_kp^?&f4W77-YHiRY|M3|TOHN$MG!UUiWS7bJ*R@R^ zH%_O68Ywh=aJN=`*r022E^aQ4iX$62U|(&!rM?33p#dJE2`(zf9D?&_KC1AY*4x3} zJ3(*{`hAqzUTk{E(RSSR6+X%_rIrK`OtqFCJ2_ndJ2Lta!c@FR8^>-7Z22@^24*k_ z3D`Xsp+X&?_fX9(W`jE0epRb>p_66eA2I+fl+drl&mVWwrPLsQfc8; z8I0n;y+Z3+|2qU^5Jw6%st;0w8D3_bsbB*TF{#5PHX~ZG z1kdB87*U5U;_vI$_2UzO2*e;-#byDPZh8k8TDc9&Z6A20Cg5}n3WEQs4Q4LKG%d6D zaP%A^jnZ!jG95w3N0;5j;4{JExM8bhu;u?Zm88gvP4!4Te@y)i>)~Xx1mqh-LqYT zfncjN2;D+Lv3*?s2tWtC9(VYQSdKmZm}bF2rqsnvO6=F65Yk z3~GR_*MLq(L0D8{*m{qBc6H4SiNnMHpbN)@>tNSYwK%N5Gr=u2)q(49!7npdjqB=Pm??}p@pXw+*nm&ZI zg2oL<5yJY|Rfl&cg+l}zM>bhL0CQo3Fj$G8te12fCMFUPzT1Vu6d?R<7B0>$hPG}@ zYJYM_Lcqp8*o=LM?};=>*gG1w_7y>|#JOj-kp2qVXo@NE*1N`5TKbcallX9p(ylu5 zt8G#$zy4VUp4!PQrK;-s(8{YOUk*HH~FImQ%J^;UG;Yf$dBw-a848H=t zG$yK|<2MfE;V6+@Hw)fu6p-+qL|)Or)i5#;!a&P}@pgVK4U8qeF2Z6({AW~`^u~3- zNer~`FBxtv+!RXO#k4m+Ldn;n>C{Lp*h za`eHCjuUUqM|Jo{TJ^DO(mFSCAM3mk9WcxqRe*e(>?-HAy2P$>4-CfsOdo-)ppF$c zcLmfHs{J(#=@keRVqE&F8+NS=YU+V$z-GPIL&`rZ63}Te{eBMW0s!k~>ZeiuO^1n_nmY zAvg(%oIrnv^aS8Osw53n`xYW#&NAFnXLkDfhuRFCTe?7X`5TzIK;M_8kk9ohcr#+h zV`mSL2;YKUG8*CRpyKL1r?Kz)So^~Zh#M`Os5sUfUQcP@&VSw<3E)Is$B*n+jupYQ zN*$@qI5ZOL8)X4l)e6R|GVYP&U{g4~>gGFTxjg8%<7(6Q=@RHT%E(^w%O!pThzTG) zpa5L-x`AQ9qj%3~Szf3RUpy3(;3hO-X_lH8FIX;Lc7&F#)$w*>>inmb)w0#&eIoAw zKHba5fUQPB2utO)_A~RVMb`jQW}>rkhlqg0_VKAztDUm)9Fr}I(*Px^9}s|P z-|&#arZP9pNBqE(?EHQVCZ0^1k%KXiLldRI@#X~+lNR2t;vH$} z@bBVkR~Z9`GOE4S0h%6d0LS^)^IDw2HOa`x=}7+h=H|F~&P$#-XCl_kCGR_sur)@h zZ(QGr?6EemLn`yyv5&qqio>`e3?HN-BwNEx{ z*eV)>DtM26=1duqi8yY$?<}i*Fy1XjJN!8=rj6aO6rxAFyHscx;^^<_1;9##F#QaN ztY}yl!M4$F7@Bf7QsLb^(&=R&Z;_(fD`Ql^b z*k9pa8Z6PI+^%Dh#!XTgk)x-58S6dumICbNXeBf?(D`lIERHakk%Y0t7yo*KX0LtG zLe}L0J=(%*@rVvWm}EPJt*&9J8S7KA&2;CXBEQOvVWPAc2vQ9z3H%%cA5>KRM>Pu#CFv zwx6CR=z_1^%Vd>3d7`ByvZAAKwkK1HSoisElf!?Ad`(5v`Lq~YF( zW=FzUeH*8|fTQn$AxJDh8IcB7qWnwj6r766cI45y)}FUtB#PJkDqan4c5D{P(Ft^C zIZLe;XYaC2G2%;{rk)R#;y-^Mx@K@NBm8jtfCkZ|9OV^+yg%M(61s~izt0>_MOmf4 zWqkBU{zx+6!#hglZ3lN0EVQ&JwVjvE$>ZvV@%pIBkh4i*Yv1v#|0Tub)bHrwcQ{6$ zPVG1M9*YwUQDmKRPsTOC%PgFd5r<^S8<*iiI3dBSfs0o-x!<`v;_UR+AKsGt4r{y2uYM(-l20fr+9MlMrvkcLlJeNb3o12;ty z;ZJyGUCD^W4)<|pBMnx}=XwthFl^49;S$3Nejt*+ZMWjpD>5rR=f@kah11rL2ay)A z-xhi~ns1z1zA5!^ai`%hatv*YQUeh@{N&ho$QC9it$!~ER-D0q>7PTg)y{G{dimkS zWzmOe$D#xNR;l4&F$bS7HIxWmBLoGv{>ZB@pq1jsn|? zkr99Vi#Ph*dfl+sAU#4r5YX_sDqUga!NI1`znE64>Bw;1REb)lw8P zMwaF3r7jtRCqSdz@J)G*ysXfZOv#V1s~`n|)7lME6z4LMWxlWFoj*EoiZYQwJ4m0| zG2{tyoLXd|q)+!{a%%XiI%#r@t#{}rf#2~f#y&HR_0)c%plXd>t~VT6EHh3rtjXf> z6_P^55nbZEsV8$5jK44qr?IzX#JGU40JL5IS(5A~-U~KxIR>9kRlJL$T6kea!j`HF zy9}1CR@QdkS2~z{Mg!_ceVfe;&uvuIZJ6(v9BQYxeTuxyhP*awI3*KMt$Vl(#Npfs zuVJDMrSqy$pN~EXy@_2%5n5*|$tyIQyP*r{?1oG6^sW069M#1_U6wlXZ?13jGFM5x zFW@=tW%yvHVDCsM9EF!mhBcWFx1%!RMo{O-5M1h>#&K*NLG)>SXpOjkmEplZ3%$&< zLUrHOwDPh}J-^|CmyMGG%(r6TxwUq={V$?lm0PK)xE@K~&E4zUKTzHa4sZ+?({r|p zh=y~(yl!zL`~U z4Pc*1UnB2VrR_itxoYhW3qsXuZ^3oc2DOJ;t&;w?_N0F0&RyTIN1Tr~LQ;tp9C+&-V0 zN{iyjb}8xp?C(bS(`~p?S4|@&t7YA88pB9|u~j-ReAn97plE-Y$j@kZf{y4!bd9dS zs}Y;r>9bEp|y4up|hPX6m-Lsp|Qb4B&>>7bRNSS~~jE z6+()HA!|g_i|~JD)zZ}1ahhTGvvy?VF0Cx=(BT5%1+t2{5I*K}FJQbS5{A#hisY9a zPgc5B;r8z%Fsu>#IW@z;6wzD2EbKc}%|7Y{?mRljZREt+8>wjHSLF9EL8f+!& zAJkArMRZHQ@!!qTpn=6^j`m-G2k;sx4hd)Fv?sMvT%1`X zaD__`inV;x7tA(!z*Iz-^O@?%%$|A>hJqb&0f0x6Ffl54;qCP=xzzPIyj%WwH(8f1 zA|AF8{MKJnzjCa|jH*D9mjDJ`RyYwKm#4-9ih9!9V`49683|a1&=ph3KY*>6w>ei{ zjuAfp@KhO2${V5aI>Re$LrigzqQ?e`UidpZ)ANw@iDfwu2a?39;xbT%-wz{ts3fCC zaJ&`_&I#fR|8Zg1Ll*&S%@6+;T>D`OyY<;8cvgZRD;TN&qwKw-n(UT0(9i`zL_~@R7!d?SniT0OCcOvKLl_LNBjY7iX4I&DcNC)O0CKNgj{+^0;@|l z3YD^&8n>FV9kNHAqMIz6z>#f>6B_Q|rtQMId;BkZPApk-x^GI5{F~9_g*0#(cP?#k z9=u)(ZG>nXv0^Lxk+LWW{3=`p4&R523HFZner4`8heDy1ci=}Ofz>EOR`Zh*+#6cTi%fqe7tiR=4I3Bi;tL~)IoYn&ZO>nriX_hIRFEp5k6Gx zU6h}UkA1RFfSt8nsypz+Y`?$;DtHme+J5TdtxrlwQY3=a8s>WP9~UK(aT)f?xxmvs zX_upm^-hDatPIWCA8Cr=4bq*vD$_PRHKlhNZ}F^5y%*snp?B)XQh==}7^##0j8=cK!sdj{|(Fgf1b&3y`r;qPZOpP=$>0vI!XD?H}oD)ajt0&YTNrw-j{)0 zmX%Rl`dgb4!luIaA&l-~^Pm3)GzNk1=isby8n46nK$v0k6*wWLAV>OCjHafonV1>B zpbQEx_csy9c_xFOY`XSsb>%G`fz>@74<#8-?k|&B+!(tLJhpDa^uPe_3(*}Wn7{i< zag9y!P54Fq!pA0sF+*`12Vf8aS{s#tX)o@4Ief`pAHlGon9gLE2it|7_|;#CJowe2 zGwl!x9iRFRL(9=dyJ@VtRiuX1);E?(BUL~9gZb(pM{`pY458d{;<}ciG=>cwM&>VK5 zxvf2+i!x!#%HIv z2c@+B&0W6n?Scz>k_k&>FMs@ zf1c_w!3Zn(o=`QUnMzVJdv@NrA09KyA_P$RlUi@SlY^B@ETr2KPP6gq`lWURQ`bWO zMmrc$g8cX&#d~Y}@5*qKP&7D@>N2w(;1~}Li7j{EXwm+I&eqv^9U#T1+<2%7a-6_v zS<0`iXz!i+YRim54UijJwA?V@s&Jq1q+D@&{762?z-#l7y;S!@ap&~L$cy+j4ovmE zP2-9avvBdCfX<6Dx33PogSGx@=uuq?^1~m75w$`K{1~P^q&6GlIUX6(=k4(cXV+5qH9dw3 z+_2VoUtRAFas{^_`)UFgP{J~wLx9$l&$z(TaV|{qX+cZ@{o%?HW6(qLCTn8T2oI*c z*A(|1lN)#9Q{`9~jGg&=zOW#{LU)tAHTn7a4->4r=dtT=i^^A|FphzzQorlg!rIql z#zKW07vK5ar^{4xZ&nbj6XB~)r^AU-IawMa5>C5=q!_nJIhAI09UnW>9~+S)aH{3W zjr0IcDYFsinzB0t6P=3U6o{96AYr$J&(CBtncfhfiKt+nXZkxdrlQ|sx8BFt9c#NH zNy^WEp2*zX+-;1i$8zzH2bHL1(F#rkfz>sB3i_Owe{}%dRkB3^lGv&De_UB)=#d8- zJSMoHN^`}Qa8*M3HDt=^uh5(DrBg&JrKG>ORKmjr81ZX1dwy2nCjpqlal9V})*ToF+(tz^?6|pkCvXo2_M4CH za_`hOkq^ZP93jFAEKD2js$YNosDH3OV|Rci8prBD;$u@H2y{I~x?CfH=Z=}pyk8{! zyV*TxBH9eh;ztNH9FHQ#CIy|sDSyh(>D9y=^hV1*O!+nM=vNNnrhb9P$2rt|m9iuW zVl>FQ4Ve<@JA_^Hc67R$ixlblEo+O?os?!o^t$f~8=ju&26+{LP38c3cEHe^2}7+z zF4eKM`)>McGdetw`Banz%MeI$fcFSTbIOeilP&IYnr=s**g@4y(xMglpv(MYFQ%G02U2k zZKZ&HboTnv_T|c9L>;@l@G3A(<&A0m2VisIvdSl7Ip9a@uBfTFJoTAxx_bQYVqw5a zG}^)#29G---imbR{Adn+eHo^wzt3vIn3`@$m{*QjT=zA33>^!6qOO6;G)JoX!%Kump@uD0MO;Yboh!oA9p>(yLtS7q_F+ z#b;k2GY3YJLl>L*^317Vr`<3EV_Hkgg{uo+WE#EddqWO!;Zw6W+H$7K?@a4sfM4`{V}&9o2UFkVU6Fi`z7siF zI@8hEl5}|W$|;YxceBulYgsz(TV-^Cd%MSG3-kjLy%?$?XJKdH_m?cn6J5FIH9OL;{47o}=OvAC+_!5S5tQ z>!Pi%pSkBYBh4_7XHn20q7G8HubheSpxuLj4+(K$&s4s#Se5pge8$^=ZyK-xw#`~W zp58uv)Ea|Na3P!t#&9%J@RVK7?`q2yAZ$|w*uFD9Q`m-UyQb<`G?tZRC|0@yW)x=7 z_)Slq2sUUWm5uJ0!G(^|Ku*D;j@1K%5Z?ke-mjX3+Ty%RtnyxIXKK;19gtI~5Wz7-XO?`^DUqyz;G!X+24GP9i?>XSm6B};=G z>XEjJjZx|sKPk)Aznd8meFJbgL z0-37YpzGAuAbk&yUY?QBA5w5jNxJBO%QMXY?#mq=!_ipP8|{*>4Sop2KKVf$v~5`& z2s=MM&VMjb@IQFJw$m;eavcvfQpPZQ4*w?<^;8|y7I&`tDe6*O$1j6RKjd~n-%t=T zgI7^sBlg2o3XV<{GjjjAB@HEe9UADa=IV&uVk$ zEIQEjiP#rPB2!=b&g?dfuq%b#|{Q8)jsj zAf?IoUkRg6z7eE8AkHma{5=k)8>}HLRLw#}08!E$>X;;Kd1`9?WT3!tH6pX*O;>&W z@M%qwAf%|k4eENEP$VcpCw>N;$J`=CYFNll+%h}<3HZa+NwHjT`r4dZ^`d)VPwc$i z%fMb(UD|QRy?5WY5#?U=JNY~dfMVYhusQp^|M>Gf%KnYgY+q-)g7(p62dBJBP_(%p zU|Gq=q@bkq!w|O>V-w`2n>naS7Wb|9bB~Qwoi3424)O$W)TeDLygt{rvc->^nO?+0 zN|0f62Q|#uXSOX`10D`4c{O{W+#5;xFKPdq@B2)eP4Ffy18^%0gF51`uO;MsHey}# z_yd@m0n5BfjtZ4Bk>P#-K}8onSGYp?@;TlYxm*qL&(Z~v3&pykpnxcE=}-wB7SW%e zG-K?KtEWVs#T>vZ{&a|a)c=|3;9cUN6O#7ul{T9`KNcw3Iy>(Q7Q7J1^16G6|TNz-3{Mu`mjyk^H|yW_E%`W z+|MLj_s);5_8AtE&!x;M1--(s=hL6wWozeE&)u)nuG7YUF@LxlDXIK;6nRF=;)%UP zpojPv4bAEAVrt;Nhrf$oN(J~1?IvIdWrI@g$)CMFf>oj#g@MZnzU#fYoZ^Vvm64pY z{w7S&Bge3i;+y(XT>XdEr5?3MaW^mtj$^X)#!r|*eSk4!yBzcBieA>ldwI7rR4`^k zF>aKIhwF%br0JK!wC1q4u%rAQmJehVo#~d`+0NtE_@}e6)d#_rdd_ z%64KS1WdHk{}aEAR`tsJqPzCjz^M2+gQn_Am~lAKeN{=K!;83G`s|8t%89erz%6zplE57;w^B z(_Pc0ObZ9Cf8w|3`P{l*)Xe6*$X%#5Z#c~puACM-SKn4l>a$AK1##*<245&(&FAhY z;Q0tPKgyl!a?%MtZupf^8Fr*`J?BSi$x*8Q`03G@M1=zewZaTd(F7`6nRF>T+FunX z)~yf()YNjtLMNQV2!LC6-=;QZ!k8429+MhBo`f5fEctv>>2A{ftMQ}Wqw%a(#eRV4 z=DnYk*Q4hkynL)#;?OPnb_%FnfD6&ye?3;a7r6<@dkZ%XWTejR#PPm2nqV-x$h1Y>eJ5CI@CyYF0G4w5 zHz6F^#JQh4PFr`?l{?)BA+JYIg$Op~RU^J`>|Rv4cVyZ8s4C9GV>sU&t5@0!pz0?! z3Ilh9{K)J%ng}Iz{4H;*9h~g~A-kXU_2xFt>D?MR?X5_DL<&d##^nf77|*fpuI#Rv z?0arYjhyzEn#=e}J*-cjca{|hh=k#SJY4s#7`%c4>xppmC3xW_5>m@cpUaN?#d7wS z?WU3t7FP|BJeO`_5-!M|FNFyYa`=o;-O;-6=^hNb%Sm0J`qe&Vm?>DX&4o$q*Bhc$ zKi4*MK$j(S_hit$<-&5BO}w6KHAT!684v#~yA1pKLux3&o(_LJi5UUcE6VW(TX(QL zw55u2R%qj=;s07rs=I90qmL+`2(xA!2em5TR{H^&{tUB5E~+sNIKthXsWt`&_a}30 z#nhw~mPSLK>rPEr-Nm6~8JXUmnnIi+vqR4B9Ds3_pAtfWg4d z1-dJL=@)+Q)~#b`^yC{Z>aNunAFD6mTCLx;hTHK~OG_Sr1MdJFc)zh!c0v6dmikX= z^h}Bb6S?JTIRz-#e9y{#N5IxMb*@5s>MI@zeMO^r$um&BT#w=!RH*E4m zkS!`o`#}Z7iXWFG8Un4<#g8Tc)l{5^dT~wT#QcqcI|9v2LjGG4#nqhe0L0mGh3 zf1hdUaboHNG#M#S?hjem+)nS-)Urr>Uo)_4t0Hw-?|jKfw$O=gz#LujSba2Lmg3nP z?`ZLd|yUJap92LgZ;d z4XUvhmxfGeFrn5!*s+fk;gmmdyYuGPts8i8wr#2D{NuBq6NL#@z=09hc&ihawNjL_ zOG$eVo09SHbo#JJ#b|KTYPI*GApj~v1Dm$BrnTcX;DlU8{XBfNAylq4= z_bf;(U{qL4>)`CT)IogJNL(hf?H*rs;uU%jAoiLDA+mEE^s#s?j1gLhlx3%8xBo=$ z52$okarRqeCyOUB`d3}3Z`HAq&{8Rs+u}Pdr?r{B*HntTzrd?y!NagUY)cdt*zk$C3**8FVWtHQ!s-Wb z|3a(^Kr$)(HWYQw zs`_LfxUF8b=X7p$>%eMt)!X~2vnn=v1J10I^NnXLb2e}QZCQHp@N#vUrMSj>^2@K) zShv}4NK74Ag|95bMphv6)JF?uTmL-|6pN047X}d-upM(zHsE*lJ=igHDW%7ZF}T1c z#qC;Z#DYXE&W43x_!%9{irgNR&>Ii4$l?(OWioqf+gy^;Q)t;8N{`=4X4BRs1DU}8 z%@uHK?v%W~r@bUoR*Nl&=gkJ->LW-^p|Ax=ICkR;Q!0msimoP2rUwx;yLeF@ilDKx?JcyX_y(7Onw6+rgRhI~45 zlyG-Bh1K-)w`y8VD~S|alu)bK^%!3LY`RCaz+11&=|0X}vJ-kO{HS-MuTwh}kWyRY zQDH^YTOO<{m`(cm7)#Ovqiv?#(U*I7t3xGc}P7N zo)%l}yz2z`)wLVCt|G+&A@?wrg!$ejlT^ASZ`k;Bt3B&ytJWHvD0;ZOgcjPvyfjoL z7m4)&8sNc}T<$9K>=(pXh9v9Jlaam2L=*9!DTES_^1UKnbZNv0`9T=6lbG=C9Zp>6V^a#6{$5 z8!$p7qxvqW3zbcNtY`6jwcBR4%{t|`c!iH$>&ZR`lEUb6T@Ksga8GRTI7HE~bNSNB zk2|GMjq8Ov2)sP>7o$eQMJb!15DLQ19YJJmIg-HKUpj$$gY%8gHoC3eizI8W#b$W) zWCR$+`_Z&vGP$5w>FkGF>%LJk4ra!Zg{d#rh*?mW3wl8Vngk8AT7* zzz0WZ@?_mRGBdQ|B9KaO(#D>J|4!xP+YpM@nv1KubKh+sBNxY5X;gBd^y~y4SSGtgYK{XQ%qI44-aK4lg3hw)YI~%jtKy z8#3L}->PfZUt)tMB>;_w$*B@agG5&#b{60^jc{Xn!m(4CRtV%isclt{PJll_a`MbbNH%qcwX5Eo#6?D;z z^pz>Tmhnu1tZ7FYUp~GDK~?IWR<3qB0Cn@HaDbbupKM;A$0Yebb%zr4&VgCx^&3<(0^D_?(MeP>l@tuucNKF-n=8DvV=o*SwjOB1&3wczp`u)a^nji6$g ziNW^r_1(7O#G8-YI4@0EcN86dtuFX3Ym@jqt3t1}u_a-w#5#98>@gv@&YVgT+iN^mwlLitjk;+;X&YEBfvxXv+YrUQ zNJu>myY~7~IVdcwy|AA$KcUU@lgNwGi& zf>shS{5moOM)^{E4DW7b8Dg(NZf0UMU`wJbL6{^OH-tQHjwnG2iF45n^)(#Qc(h?ip9s`< zAfY0Fh~hf}-zI{{lRpe+ll-J(<(C`T31ad``c)UKX>}_ZN!6(ZtcMGq-#o+vGkR$FT zBRXcDSN9{3{C%w=O;FF)jozNO{;-kqwB-)yOfXKB!kVhM7F^P)2(*t_=Kj}5TZJzX zP{(Xx%hZH0=!nP*Ilc-_ILd}iKyU9(m>MVqPw?#eZ*_WBP=)qkjcz& z>Lt9Sh3z$EB4VyAJ}GW47Y7$Ceff=G1LSxyorrbgG|0GZJ}#dRq5lJ} zv1*lzS?4V8y&=$hW8NFLz4|u5D=p;IrSGF%n{zL5po9VHsb!`Y+?j)CIWNn-_qzT{ z7^YnmV7(1$|4CBrBHP-OYXutOhNEU9I|o|a;VY16T2x9`f9ypBbWrMPOO@rU*d|6} z{^nz{!Hluep5=p{JxS%xDj)lwEI8jRr9<7I__~w4CC`Vc)BO4AHR}xH6bR%k%?dqV zj!9`CFxH}LnTGoiR1%9i~O^>JA}~+eY*>IZ_#nGzA+WXLHEk6qgOsU0-%6S^+njv|m4V<+&oCt*TprCr7>~FvCRo8W97@%}L9kd{ zVhY*={al+2D^;$GPhn3?)#wA&nZ z{pzEL5(NNnH;J9;A}o%Da@iZLtbRR1DiFATl4HvWbk}fO(|J(cdd{^6)FS7~>Ls{C z7rbk@U+T~Teb>DlSql{yQ2+mG^IE$*;MAjkB_@pqtwp-lXT}POte#Gwqr&DR?pyE) z>ulW*pzMLDujXBE%jB$K8bTr@Pn!cdqjhH&N!cwTbZ48WnAy2QamT0$dJbFtW%-`) zAR825H`slrLC_#_PCV9I6)(q5w6>2<7Es_ZWsJLS>7&v$a=t(CGvM*C%DeEnnk-Mu zROx~4gHKthI)H{Ky$7GU@|#+E!^gm7gd`EwPCRT*v^`&4ZD3bJ;`aMIJ*uE}Zlh$e zl>)aG81mRX%6&YDvE;QX!`M^Nx);SQz!-a75HkfSOP?I7Q$iN7S5ok_xDf^EXLwK}^=mA%Z$gU~%hq3a~+qf5zg7yjZHMUjPpd9RzZeRIf}YF-B`zd@DjC((7>q>Hvv zxndUb9e_IMzeO`y7yPk$b2V?}IqhvD@7qh*=HkBIsPr)DC3+wteIoDe{;F0XH7dud z;8AnxaLdM;0THO(VY((Y11>Z)b*p;*VW<o-mZ!8_(Xbw2_}}eyySXjo+@Pz5*#;z;^4NY#@(?T zsLbcXccM4y9!#w4$U>w18xHlp)|qMO57q0@^wk^km9yQt5S440#XXRO13*N)!uJub zgcr8BzJ97idFacf!AXvt(rdQAx?l2UK+AsH z^Y$!`->NJ)FH#2O+Sh{&XTUHofg6)L7*RD?!p5EJux0U~3+$K&KcTbE@p7 zvYs7QYvud(W9`qVrL_UA-17=kUwZ7;%-7%8?fLnOeX(ocmgisKUL9f3hSH>@g+lFu zNRUhOzM4}ob781OtR!k;jJqMhKkYTnGgd zwBEqJ6Cp8{Do6DRiB0*zalE!3{gQGAqht14MRXto;?~o^tq}Fu;Q~+0vUx{r5Vh%U zGyy4mSQRQ=mZRTiC3R?hq|Br`IxFrMHFC*Vki*p0GaC*Oasd3MyD<2*i}TqxdCd;&iH z9yo!YAbBefCitP`y4w>!Hj2lzP1cUr(GB{a)|j1ndM77Le;bEMzY93ymXWtEkn4Fu z)YT2QUY*w_vmWaHN$y_8XF3yW5?WN#kcTy6&xIAC+bULx{W=DwSy`!l!m-)641xM& z%8$7S4?GuM5}>wm*M>COTvGR7%rbG+1-PvsO>}7xe<6n@9`7{!@CJEqjHGlzQU|CQ zZdsT48c6)N=Zf;J6`fH$(WqTxUFLqB#X#JAd&CWxd*^eyj{871dj@0qEd;2aZsue- zgC0!@l&MRK=h_4H7Ktbe@}W~?%ywv$aUXQqm43m)INiY^P5y$(2td9A?YmvuBhkB% z*r{GMKTiNz`^85X)|+_(bQib{RU$Y&yoZ^?93=72$e*uIbe5*CWoq^Ep!8ml;UEAU zN%*r4XX;zZ48$&iQM*rf3HPG<=p&=KpJiJOhS-_pVgpU}3?LiRD|lDLI}?3rZvN#3 zKfz$*#S1TAth?W*^%;0;UHb{NIP`=*Y=L#hJ`6j;U$nO4>@J74#ZC3NfinQE0$!qP zO(JrflSItjbK70*`Tavx>Nf=z2qSbUu&eiShi&D1f%vcAWionw==ek~?m=Dg;>)X1 zyA`0w45;Tp_cr}dMFOU%QK#0r^$E8F0_Zp`(b|PSwp-~Ib*Ilm)xndB!-SdyfsI!~ z8%a3m@!JUWGt;@1nklYZ9}!1!$mVJ(B9v(VpSj>9tD0wdsTt7)SfXU+e zeVse+0FwqKR-U7a^^TxyIymBmCXTPplVWk7x#J0Lt`_DI8sMF;23Rnj2odA`NuF$5 zq70RC&AWp#R>o3VX9%j}=2X|;N3vq}tLEy>vSooYaq!j4UX*)*Q-u&D#+t!r)M6de zV26Zz2jxsPE?_m*_+@)vQCJr%cH!zCTeU@>(~79=CLhXr-iol&OL}bs>R63W790c*& z60`=D9*_{bHSvm0fAQXVW@?tx0l>TX6NDAeuAq3@gVWD7J+Ni|^~BFx*T$2ZM5iix z=Y+MZt~6cLCaH2=_C#Gl$fa_%6xT^} zQLh%j)ajQFKhjz$sXOnUecjp#jIivkK&c1OZQkwTkmO)x&t7A zX6LM+A`wcdd+z1?hF9Y99LR~6CGTBX|IBw{lLJBB?&p~@XR

t6`Z5TCF(3W06@49{C;zW6VU#pFpY_{KiKbyjt?D7ty6$Y-FOuFJ= zI$Xz%ve`zH4d!t&dsj5oEHN;a+!i-~*)+49B6A%G;vi{S*S+67Kfkee`#dm?d1P=$ zGyCl~^`3kUt)gz;1RG2P^7VkcHKrB$)>>(9Zm7Vuj@WkGm}f?NOdr6&`SK`I*d*ct z8eek-`~4d(=%7IrECWF|z`maK{S9Aw7)gD^H?j1b!|ksJXQWZsuJy{)FMY(o$Mpe3 znDqM0k6f6!p8`+h>(?jCQ4g}N(TS_GS(5jZ5ncz4(x|4sIWgu+VkB=^Yaw><0_mMs z%XQ8SeVoXh!P)2#L>P7Nai^w63^yaP!UIT1g~@xDQP$o?uP45*DISK&X>R&{JpPbO|Gjxrudo}JH_sdfnNNu8#x$OG z?hWWWMk`i1yNxtH3gEL_q%89%S!yR=-l7t4&@3r3e0WHn~z^xYk00Vce0?m^FE zV?-+Gi!7^y=|1?9JZp_uzwevJtue>sn0@69Ah9rEAUhpMn(JmT%5v^qo}mXFuxsm3 z!FhSA`qvt6I@RMrJj3Q<*(_oT|Y<9l{~CQMOE|8)a4F7o{pwd+!EL84%Ii-HDO&vTtYsS2ma5J zWozwBiTe{)vns#MQ~1bD_|S*|feX9iyWcm%rbyn)4GSX~hkZ5iQ4PNAGM={I>BjMJ z_(zKLJynU>A6r1}c|%oZx5%xIF~y;+)BqGbP`bUr&-^yaK)=IqbTx7`PHMV8IM~^& zdf>`qB}2@>t|ouc!!+J8TLj(x_My4x;%gJp5~jQQ7v6raU0aSMuq3q0BBYcX@G@Yg9kNtko8Bk1%9#!7_~?u!!X9;$ZWV; z8bt8svKtxu3ggD7Oh3M$a^|e`G%?FjckjHZXl|yhkxJ`ImYCMuoO`M&kvWfz=D!Sv2@Cdan5a;#C0mmaZ(qqCsbONZ z%4W(!+s0{+J)bbXqGJ%9G$HnQtlR%rMnTBUNS8+G`SZ@k1!wYdHfz_mCFV+BSbF7d z;_)V0_6g-qUSMxa`hlBYP$LWoZm0`V{6w-Zx%wMT`Ica;vce{E%*v2^~ zd*G}k$z`5oowMJ0hj6z?nuGm-dAip*nT7DY(s|oXu`M|z?(D}9eBUJQ2$3@AE-7$4f371~BHY2Es7nTYgeN>TF>O?5sRL~ zwfKjwUj5tY@wEk=UZNF;Wq2*J7Yzw)Ir3uySDqXX#as;!Iufk69}j(#81mn5w{3l< zUrFRknvL#7!o5MLMimW3FRd=p7888!g$&AKiH=eZv#G zRyEaNS-p*_OhHKBsQbWc+!<{jT)@3rAxK*5oN&MBMG|dq>Sdd<3DeeM=O?+fiPR3# z_}Klm27Mi{AT5RT^Lrez0{TR$j+{vS2n;PRMn8i8vzhN`BI=*>b2BB6cB~l1~x1MOa(P+?`1wpgz@nqQCA z{xS?Q&C)LpRFmQtT=3!1$2a!$FIpN@*{i+YTUkPu+eXwh=3{IO%g@-Nj-RYNI8hh| zaFtGa)x78Y4=wtNcZ^w@<*1#U1>2?#$X}a1uq}4<2F)|A?k46|DC2J6oZc6dU5|#p zjtSAI5cPm^%D_qj2`t8fv74PC^PNSICGSPGX7Zchienf z!j}r}6raZeE)uG?j}WiXMTk?NFB9Mw>^L>i&~B?IkZXS%$m!|+`DSfq5a=Z4rL)|d z&g>4{BB)~}VL;Mi_Qh={tkIg-0)JUuss6ou?iQlQ+l)QCvR(5G2rP)XT}wr8 zZ#vcsrwZ^U(kQ@RxC5%t0idFBS?FAnBJQi|R@9Za0}1L=%VAJ=)0FFw)EgVkfrxw< zxfQ2xk^J}zZF<;-*U8rhrdA_pH&-?Va-v5}nHPV6+BV{Y3`x}ZN#%y5pL=0|jgAs1 zCl~xPEIB^i^Rx0h+sgOM$*XbA)cxS6A}!u1%gu18d+qeOk<4~a`c zB0=XDNE!h3d^UhN8$z6!YJNzO9;mf1{o06ciZ3D4!790uTCKhT^;?8N0Z1j2-TrWK z*$;l*Il9b;KcE|EpMlG6FR7>XD<0vodLlR6d%5OnK!_*Bql3SrW-49upN6#fpc*zH zS1FYj^Hgz(g2P00ZSOC_DM)8L9)iYTDfNsB$KqsNwowxUD{{i!qE)m%;(`> zn1{t0@dT|=1?gJ9C7HskFe1^U{=7qelzcrAbty+be7%6sou8S=o)*PV2zGogPl%{gW; z{q6WEiVL0L_!A@y&X`}4p@!c#(8uGgV#t+p$&_D_-m&W|)O{fQt-~_{Kv<rJ~#9XA43G?zS>Ys(C>LvS=b36u2EI{PHuWNV7AqHkEZ}nU-~C=Zx8@b zL^JtKt$OW)<*QeLyUQ}t5f1?1v3hR}7sm%vQJ&X>qlXb70rMZ-)0XeCI?TC3--3t%6`#o5Fy_z*9MXgN z4d^sV?Q?I1PeM+cM@avJNBQY*FhP%9ij(=X-=NQCnjjwhm_CdP1<}qOEv@`CeDW#83hij zdMRM+aHl?HGGck*%^puAn|V6lff%(6 zO_RuB=>%hJKzB#wbgQkyl=nl9ZLZ94A$&ywcQQh$J{Mj7ER7NWfcrB$(!}bqbJDfWxpgzJC~C@8wo8SXf~NwFYV_CCm52@U?|Tyza*nm&b@P1h`r4LQ=*<76IXzWBTyF( z5TdoOaX_O~K%;3%XCCjSB?4#7^sXW5k35T)bSMNSjwBOERAKDBOoF_5HmKJS<@bEu z-tmGC^$nX5Zy;&}Lc~-z^iAn&XP;jC>unUWea+-eCV#!7_TG;*Yy?WfmuUJbQo$bI z^u_P79?`O)$JL@DO`uIz2GSn$wD(t05()Z3K2q~N6oi`0Z^TYkj2_1%T0ugyTQbv(rf-$J3%MH*Y)UQ@c50b}&Lx^>4ra5`$PAAs#&%dnDU9knt%iw@OQw8s3 z{G`JIjr$7d^HGBayPagcApJK=jx*#Zh*xvMf7EerE!gh+XLciUJ$AV5tkqHq?t|W0vLCd)lXhYSVs1M$8MtB4DH#BK#siO<(lmN_bmw6TC;3w;EbZ{-oOxWc%5INbK#LHcQ{LdBb36h?AarS@o;*A zP3KgKx2B=TgXF56j*#-<*D;PGS$V(S`5gswCA6FxA8u=BD8(#XB|Wgld^PlJlTZZa~mo0q97ftsI`bG_CQgPxl&>;PpLJUPm-&N*&rgMhFU^aI)$a}~*Qc$E zbM$do(e5qd0USBpxr4G^C=Efko+#Cn6LC;m@4PxNtshV!BW{eh;eY&!MImTZPM;3w-iG?mAhNvR1juB% zJ7A#1gZF@kZt36GLZKWQ94AhrcseWNOZ7em4M)425W6ckIQ%Ag<(c?EQ!Z+mq{>uNki0qxR z)q%_c6Bg7%djScU^^NoSsKCxAvZ}ubl5RI{INgfm?ZelF|I>`nK<(Pw5;p{d>ZukY zJNsA!^u|V_UExclDzLxV^FMqR+9KcQ4B9;KqKE1eBMJBw6d|91!$ZX0aE|-@uWO($ zS5>N#<6pf`^n*zdU=^CJ(#U-{{tm19=O;L>Mbri`POPTuhAG~PRY3h{oDBZ3E%9v@ zxU}w{KZyUovNKdGK@E2>osTzo_ACI*WH|3DC7<>ZObliFzdqayHDs-a1P;7v;B7p*Z(xC{-Xe1pXEz=?tC((r z*H{13!S)h_CUQiFUZc)a+@o(~2{9aY>47^*9I$xU|FH)E2ag9f*kDc>ZAct!Ap1FA zM);B#*^1(s@BiZhc+*DoHC|X)h{qdIC9(7+_?`d76^@DI>&SU=z<-oz`HT$h12qLU zMyQ2U7tG*e?yUDz`=BJarXP90X8nJ>&@d;_w7f~4$w_CIJ^dGy>go{A(?--nvjOq; z`cLZznTWH+Fcr-eZ?jr@YzlrG;?M!|LfY2&3|3tI=fz&}VV%?Za1zriLy6W4z^Kl_B8RuIo){KG@|vN3A&!rfn%41be&{GY?aN4Z>gvw2cNtD<3{xM zqfKcxk163)KmGhVwI3N>xO|o5$5sAsQ(_w?wb0%gQaXQe@aTRpGC9$EPcE8l$LDsy zSofKi<5yY!i#IranaDPr0dN08)xo>7og$w(3%4Y)Q~Qt= zm3#ZYe%cNS0YhNqaKVM#1*wBCKsS+twqJt}_k7I>HE=C%QSczAAh)g z8EsityyD)GIhJ2}hG}U$7}oRe?|kS+d>9!{MTFI5W_(`XXOY+lWY|HSB^&&F>;Luh z4ZvW_99clFM;drvLM_ zgH~PaC3t;^Li`Yg;K6SL-His}c8J0U>SKQ#9Z6BCQHRrj|H=Hpyjd{Wk6O;e?H$F1 zievaE5FnV2-2Q`UYjScpg{*#Q(7Z zih^nv;qGbo3H(zI!*v8`-Cqg})JB{+6OB*8|8e;MZ4ala;D9?s-eDUN#$%p)w7sfw zOwge2eX%3n?l*9KDEh}L^X2*LLwyb@l;nyE3T(&UeN4PgOrrjF@y5O9@947Ktdh!hH1vU93f(uze7NBQcc!ZhfeInRuR|E&4E`FJ1RQ; z97yuROKlD=`oiDCg8oFwoKscCl&d!LcJHBm2r=5j%bB{A1hI36_p1EokNa8+^6PDR z4~cknk7)6{*?-?4!Y?^6um7jL>)>iC>)H^CNU@BF2qHSZRuG~{k!l%rW@u89&=W;P zsZk6FNQunAC<-%!g^r3+5&}V_ms~}_f}r9Rqy`H}sEPrkN2RmkO8|N=JjA6k-Ay3S> zh0Ar~#(g5bXl&0bR{c0st+91_VLXONCN1^q2!3s9Tibs1uE)`M&M$lNd@^|keP+0j z415EZb+A^>t&ApBqYlaZKSwrIICqFJ_0_O*&;)3gePn7eg#2@a96zyi?Ea=j22op4 zg16pT4=KT#YLm1vXZc{v_al#qsikLWisJ4?a~%@Lg%T|suJ9`>3R9BGS|09VJ^kb! zVxHvr1B7w$sT9vwXAC!Q_gK};`Po*k$K`Q2JMpQTx`l7TmzJl_=s0&PwPfs@Ys{?XXyNG8dOwnt9{<>(_o7L2-7x1zptz4M-##kD^TX0cT()f?ZE-34AoBfWkRub*g2c z1tD#{P<pat%ipNkF&`Kn6k}HHV-5@$kYeAG%X$syt0r!W>sdR zCNhxOU$Q6J?OevJz{3y$s-P`Y#r+ThV$R^8y%OJ3jUhZ-VQO9|U* z6dkWHPpCYsL!A(hM4402a)TN&>bBEtDJ=HJg5Cim`&_EcL#aaFcn0=?1q1p?A6&Z z=^MUYXulBuhuGp$y+UljbY1?CyTr?CMnY!WkR~hxw=XnjpWf>ysyz+rlY^?RA_c&a znjoG_k~ktJJ7{Ao%h zf41dlP)ZW&i}T5S0-Q?E4*dBq4_M}?5)6ekPM)!$%T(xh=5aVNOh{Vz2f)CyIa23g zop$LgjCBqW9pT)~EJga-Uh3>DK&ohihUq7lvva>3wct3oVR1uigF#t0R(a;k88)cl zZ;oaEn0u(p#Ft>AVjZS}yZ$n29(*%Pw!6fp%$PxXK2kZ(F86$fqx15K9z39){WBN1 zWDsJXp?J;0);!i@+;G8T7|S^@GE+Zc$0h z2W8}-l~fIg2~e z-!WJPR@%%&U8}s@WKy1ty8uRtxl8PjEHgUX*Yx#w8umMH1F$7*L7J>|LbV=~fR+g) zPw*a4jlm19G;@_1@3@CZQA0`#pLZraj%szuI>e6j+m|&C zjLcvx1mU}~(Q49~(DGw>=Rb$8z+LTmIs>z@*B)ZuHoOy4T#wM4Z{>2^Cx1oWSWg}Q zz2|Bn9WIV6j$ZM_$FpG{t$mgvBoiFy_ZYfAmUyG3NX5!?z%?8CS+ph3Kpd>DHh33) z?$eupw%YTJ)!n4_V;TZj4QVfuoxQ|!1VL-hp0R7mOf{s5a*zw5=Euj@@}M^{F2NC> zr?O_BG$BLf45POh&0ozDFE+h3d(Ft6{RKT9sMj<>+u=~swNu*%1J=&j2nErFVO9J; z%N_&H{f=w3UBuvA;3sKP0(GGqnsb_8M7Cz8{cb>eGWVn3bLjle2{<3|NOQGumK!{E zO1(G0aCU9FwU`eYYpb;L$Am&q9Ias7w%T4kUOa1;NHU^?l!mG9ZKMQL*IM`Wrn=@e zB+c5>3=_@#dBTIfA1(ta23pzeoYTzOI#$F$SX}|c2D6;UgH7-!bV{bhA@FUiTx%e9 z#(l}g5QqWRUOCRbO-d!U=GXj#qMt8(a2j+_FFPMfX)@>$3#^;db3z6n6jr*S=a20w z`2_tOlVx48k}w;1?jioU1(=q8YVq^P)-=Fp>4~ksP==fM+ZLoRf6jx@zc2G^z&~>= z`kPpo@v1=I{|{sS!30|Lo0pTb9#_^T9WhQ`^)@5dS%5i7Bavr-O?YQ#pygbzwMWbZ z*}`Jzl{z&4ipRdaogMcqokE|*bABe68=JX1@P+=CdEW3?5p5=T3_(cQ((gT~eZB}} zQ-l1YMxbH&SmJw_mOiZZflc3D&tP2vk&6q5CNO?&Kfz%+|XBeb~bI~ls$jdfM2xJ z7|dZjee#hNuJXZuCZY;K7~9hm7$ed`@*&*tok#sPmLdh1C}s!iRZwlmm07P$LX42w za_;E)Z1Stte&QD2+{qu2_!~rLcmz|i@-zoi7P9hcerFF5Le~qeDChqeun*&-xWdUk zDUwKvP4~#AImU~Yl-@5Cl@(^ZzEyK|H!Dx@zO6;PGW&gA=@uHzDK}*s$Qy4l(m@0% zIQf%ur)QH@4&^-@cs}=_n{1T$QZWi z{+^k$)0ok(h>EixKEg6#LMEdEbH17)Vn=>H&040Qh~vp+LF{xn_;K4=Uj*tPQu>9(50-^2#`1p(7ed^sDDP=C{*aN7cOyFxHR}@&)7_1+H} z)+BJ-ns{ddA@6;S@tdH1oYiy1`kf{tf0%jNyVDOduKClUOs_ck{$bEU+EU4S$wbCfYo&}AeOD8%MYcsNoEZ>=)@OVht~bI97$_z z0o4;H<`dKY#@JrXx6k;;hU_1(|I9$a4_0S-2mH=~$28kv9a~TQaAk4n2@679^qWME z`#g*b6AK&P5g0%?CZgw)>R+t~yEQit^Ec(e8}MB?a&^|}09Ve^D6z2hA_r z$^WQ+f|nF2Kw+&YnK@Oxv*jX?LwKWc@`%xBtKP;$*QWXgQv`nvgJP2x0LUHJkpzlc zz;4dA)3)MijMg6#R{|2B>3=Y9WS85s^6y1*HWMJjL5!mvXhsN44A2|lFJcJ#BqOan zWt81y6yEdJY0mZEgmk4G90O}=CqMc2I~IFLX&N1%*Ltce=&IgGTHF|uu%d(C z;?k-#qEVJwmQTXR;(N**;*NQbC?Rjg9Fi86M+j2&!6S47_@j5JXW!j9hbZZO8)Ohg z@D9C^l*S_Mva*T#=KLHF&d+soB4qqFkp|3C5?U)KwKsXrBTLr!5k=rSRJF_xR=)lU zsy07AI&hEWU5*?0IJdgJ1qgdSh6!CiQ$1x}UtaF{QvyGqK|C#AnBjk4gH>>JYwN&V zqAQmM{Rz97jwlE`@%1Vw5pF=2ta8a{Qke@(WgAI|F7r)$S+8p+U-h=c)7Msu7WBF6 z@xy86aop%2tC^f=pBK_Q!Z8n3C*nAI0QrO5#l{9yt_LW?+uMH1!_1a&K&}6pn2+@c zxy`F9=!kEBL)b03bU{K0GN}6osLB+1iY|0n+ z`9^w~2nU}ORbuSLD4wrp|#X%jrS>&`Dzh3v$`ma?#hyT@5{w3_U=BL}R{ ztZ;Yz&k<67gmMSPa|Z}hUsTNlHgg_qw!jts1YuAGO-0E)iQ{>jf&!oaPlvD5dygE9 zW0dyUtQ*#w4LF6xu4hC2j2`m-A64MIkJIbO0qX7d?)%=E4Gy+Y6UD+*gwMMX(h|pu znu@d^7(_1iY<}x=f+tgD|7r6KVx_B*ecZd=QQPM-iY?Rx;BCz*)0qLsBDg=*yoLfu z<7!=&_kHJa5(QDQ4APJmt~pyBMEMPi)ex@b=X#)X(&umb-_&5G<*D}j&AMsgM>T5j zm*Jq>x+fkstmF3_`~O3tM3l3#zIL$As6PzwofSBAUusRd(_y6oZzDJ{z^dha?7Mu5 zhZ~>ph6sy$rmyX^Rjiad( z7IYl*r~N|B<=!;^R}Jl^!Td}MiCim^Oe#MoCj}+VR)|6W?z~tnIcU1zX4S^mE|AoZ z;+`36-`B7{Z!Zm~o;$CqR13vFZ+*!2_9E>{gQTsV%}x1yRqR2}XQ%{@8a=z~RX3;N z%LUV9GAfu%K>6hjgGKU*^nLPHZyxg!h~DJ(0&iM(;y4^#KIJ(U&rew3ZjO$%L3xu# zj~JFy5dhuYjj%G1u_!l|I09#N0v<=sj9(}!cpep{(2#fAQ%iQ~C#|L+5oCNGxfRdj<9gIcfvZ4I+9wvvjx8PF|Bk65FvZfanE>p>;etSE@!Elg7Y<4U4grsdpH{gg{BT4p&O_Hh0nER}rmTKh$W|{tMhh?|{|~XFh8678hb>&`sBSasAwcK8Ue+cf ztXUY)txjs3X?AlhKSOfXMkah?ZGg+`Gvmju&z3Sk04L>$D^!A-5AEvE8xCDF&`vbu zL(-7E3|mJu4c03E?adJ!d$2h~RN9;+I;gc@NR3&=pRh1OL?NvQKM+U_kO}q=!08YAVQ^cINtZd^i z!~l#JaLP~l+P<*3+zSZ*mKAKddnkBC#G|Qozl4MBZyL2)nZjp~x|?Xzqdf_R2uyW>h& zWfiWg?QB7}Ctr|Nx#4t%Sf33iu;m9_2Y#Ky7_LVu!M?0m*K-+k2mAmOhp#xFYGR4; zq-wNYLC~q!UNcQ>_Vc*iQV#fczk|j`mEHl53;a(Tqb*WkjkW4?sy%Ky@cZ1dpwbR1 zIR3Z5I}Qhvau*Ny>3#a@ulfgP(Ya6cI|eft5#(!q_CGP>Ii@UY(~qd=6AGH99|7&l znio(tN!|A?>!Z`<;eHOfQmyFFvFfojlCwt31MLF_iCbM03H&?^*u1uNa#sbbTU9(j zZcbUm)3S|NgF3cB?_bAPWF{{m0vuYMBiiH039z>yTM^@+-W{G+7;5BIU*#0F_#9td zr8?6^;LwC`My&^Mb+aQHAT7#bhaezIMt=0*@5qTBy9WCiY&U;x|=L4RUZQ zBPfA`>v;5P#$FI~a1fm*TPVfU-8IXeXC3U$AwHS${09eD0R3Sj%f2$Vy)Ria16R{3 zgC^t}En0K^QJO6k&tBgb=ZjAyQob;6?7C~+gqoNCaSoS&v<6Kq!DNGcNa*1*3BG)= z*>Hjt4)^EDtq3%oFRD%&w++b zt^^<7Oj$9OGKUTzEY;tLMh(jbSB`n~N-4OqI8Db?8^jK8Lts88sziQ1Rw8J=%2?m53ynj^oa1DKX zm2gtuF+DzADd6Tk2rSpar)lByx4va?HOK$SvloFcm!7GKZ6F+&_$$hOi69wnXX0ig|>$%1A1H< z!;cs~i_SF%g}7V_(zW`^kIjcp(xXTbTGXJf>1*x# z;@(OoZ$@*{Ebz%}8{UkW$@oiPhc2@jgesbb6Go@*zEtM2F*QJ~Np`LMLSC(l&ZTzL zc`7)mmn~QyXA5R->L-R6hTb{%x(;v)9klrz1CKM83!KHUsRy+&Uy?Cd$t^SI8y7Kd zL28SkNp)FmokCvEMM{syT8<&vrS<7Z1o2BeoRi1OrfiQ1SOmnuXOg*%LLn=fya`h{eVb^sdb_ht*(Gl6DG^Fh|`9x1l1~o873C-aEJ5 zc}GzFhe*pPr`qkVERIpO7z%9Z285&=GR&aW(9~$g>zN}U|Nzq zrX}x4?7)38CiQ?B^{;$)eORQLgw3c`Y-J>Ab1JxOgn;+cIV|yrxjYpiW&qg_h!!S$ z=kv)psprK#vxCxgUEsJIoY98q)2FE6_cH%=jfwF=X7clZP^seRIcB44A$*tH3EhN9 zU?O9H685Cq&j=bCbKkH4COGi^q&AB{be=OjcAaNk$pmYhIkS*qB}m{PZR=oMqhTCD zdL51aIyZayomX{CiAT>iZ=u#A7Lk~rYyzpl)w#7sk;z4?1%JK#eu_z-P!#+kaF%i?^5moFYhwk%ICPFKi6^BCzC>H)E^ z#XWV|ZJ-l)XMqm|xP`P7<+Bcci-vdG-_QTpzybR7C(u>90Kz#W(L>US@7XErIeDP< zZ40!nwW<*_nae-}PF%NuO#w;egAxPtNjSO!-oi}|!eXh`xELtt>#=3Hn8$65tzI;9 zD?Z0|0LL~hzf+9=-OWDf;54ty{Rf94D#0$OU~4693EVQiau)%Vm}IGBlQ;ZfUJr5_ z>l$0|1q>`YTpJ(a5*O@HKapS1x3EC7S#k8mhY$ZA0w>tSdxFk8P!i)JjLZrAvNyq~ zhYy=0sZ9`G^NF_gNL+N@cJ6Ep)ErPC{@PFS1xYa9$sW+k^hE>KrYU&t;8i)YS}ZKI z0Gkz=_->sS^HBj!Pl}IEkPj!Cqgf|+3Q>I{8}dtcaRotph$yio)^(Ylpia}2yntQh z<$){umlbT$>IEzO%WJTzzY<%c1Bwi8`_n=vUMhdki*R1!a=LLr;|BC>BCXXk)Cf;l zVz%(m&iPK6T@oy$jy-pc`RdfO?nT4dBMXmky&ifV9lam8wcxnC4GRpHitr^T*|(nJ z@7Iu$A_FADYFn06{-v&u^NJV1Y8G zz4>cGk_R>$x3ZUSxAu)0)|TKC|Ec~#^u;VqM_)e{__UeZt;;q(L{w}SUW*ERymxBf znh(j{(n9UN*B40d*(qG%2bKyc0Hc?W_jsJmOV|&AchAmIv*trx{abOTh+HghoDq{m>=6BnmR_FTlvhhwVZ#kk{n5Z)2lrD}uCtnoh?OE4n% zw}%!$zy={wadF8&Ru*V*t>+BT&PW4ELIjWwp$8lgF7QU0VCeRRC;&I=o7Zpjh-G>{2QFobWA$P8}S8OfSo4P}RU z^cs*gtB~8zXaeNOi}J6=!+V!EzRuM%nRScI56n2m)U?(P7D>9^waBs zdQNZRBLJLE56hQ4j;UBIHE%d?MtjsJh>E++DR0d+XYE^FEcCXiN9bXUc=BYtL&IPl zFNLsUyfXMbiME4pW|039!Xq9UlL9jB;s-Ij*k?OSJyHZ|75Y&A=RLs+F3Zm?aUt^> z5HeUiEO#mytqB+$jNrrmZ`{zZ?6+?gd(Mxq-jZ-R;y zn6puU8cebz2KVk4svYIjduBcnbCGN;ykq>EYZE>=b0u(|At3(*Ho$rb3#oohsN7e# ztM=#5YiiNlSJl8ZNB31cXztsEFZHjxm^`T+9G8LA5DezwYVBpOS?yxI__)QaU52hmn(0W=qZ|L&|+Gb7zNlQsDVy;5h zWq7jhyz%BdM2pq(W)kPDm>Z+>!00xS51K{61(&6XYX)iMx+ij6tRoC+q1bi}yt`s^ z*!<1p*MlJ%E3$|&HLHUsfCP2{DS-Rz?N)(&?*@UnI^-ye!8mhc92ATnaTSc&GPSvf zP=VVsoX|@7jk#AN44VDKFShqdZ{(6DE=cA)b*$KR;9tt|b9%gPWeXl$qy?I8a|Vu` z8;BqtmGYp!_;8tp&0q&{tI_+<+w-WCxKSP7_8};wj^bI+nEOJY4A}SSTVZFCqj7mq z2TwG|mV&O7bcE>Aj5ZsY5y_UUKNr(5_FRVmWRYJ5hY;q?ywFR=H>ek-abSL#9Om~GGQhXAogliXV6pk;sTUB)eUqQ?&W=%bHQ>Wz^i0W{7W*PKb~ z6oE@m_ZTkZ`0c~)&yLoWCI!AD?AEw6-vs0v4MBKllwE$aiSUJ3Xv7;AfL$~@;_epv zpHY6u>dXhD1JW0G$l3iVBm?`R78Q)++6H)?8`z8)28RWMP~)v~I4gj{+i%NrPu_>Q zlCe%0!WXetfqW`#ZvbazeGW&sPO<*t?NA*`dv{O8x&^k71}-%Av-;qM!WLFd!>&>x zUJ$XbbFixrn3C<#sa5`+%$gP1&ulHYQ~?bujwVN|d@?90rYeXtcoQL4jl3mvLq0caL!>`vucsUV)GH%#M*@eCY9 zRz+goeUe!ZBL${E$66RH%91N*bE-G)Dq#H-r{Kob9UlVM*9eoxwoC<(z2!Q$v`KLW zZ|BhX#sW+{e`0Kh@xv9R+iT7l@V;6ELa#3LJA*34ruSoHLO(BLS1__8r;|PQV1>(G zclhC@cISU0xe$fJt(9Jr5mewG^K4T6V_cXvXC`y@ETGF^;b>tHyi*z@a!5Rmb?~+S)gR3hs+|>;v3x+s~|V zEqs^AQ^#eyYvdTnJG|1{&@wKj3D5UMB*PIrr;r1$k_gHeOK_FPkIad0p2q|?BE-PO zuIY(j9v_7>laaQ^In$o;WQR-t{_>XZbv$b2Pc3 zEZ5&b)8R=icW>(Zc0fo&1$B;;Q4sjyg|;kyT7w%6yQbv}s(??!YrE^672eqnmz{$b8RMi-m(vs7hZcm@0PLWC?8O{q^uaqqg~Zo$=|x!l z4HECh492W7>9&FE7es<=_i-Eg23u_!@hr_(=zo7MHP`Swb_17@;J(SU*p}V)-=o=L z$sc1x$u;RyzcG(2D1H-PD(E@z$ED?YY+1) zqW%HysRn0{CAtKZg-P|QsY7TZVY+>+%SK1$k2HUusqu-ye zMO3VVHjr6PAkXD3=i6)%QO-{Ces|rgb`;x1r1CaV*am#P42PT&X?y=#lpK|OvYZgc85}e zKW}4V+IHj(wplirx!m`~v&jL(ExKc+>xPCPrL1 zwYafMl~QSF{A@UPl4B)WuD~N7hxe0}oQ>8or}!CIei}5zSPX<9tkc8g0P-uL!3Qj3 zQe6f*zl&Py#mAL`)V*G6jm~Me5I4u=ID5HCE&uC(2X{$p%rh~$dI+DvY9~#3=JnJs zrA=@RV9lP1*r$$k2s=}#35}ozGyyK4)wucL0sf76n8p;=!ts45tIy~!rZk87IG=h{ z@D|Mb3{w4-jgL3&K?cUh9;b?OI1Ck3*i*MS!G@c_KSw`0*`(mn9#{#Qvx1Xq${o<8) zYCbUDtuD;-Kh0dHJmEF5r=B)%K6MVFF&@MrGigNW^L60uAEEOnHZQ+`p2OLwrOXE` z_oZeACH%noW@V#&Ac2_%|Fc|eZ1;Y*&$Tb^J$L75h4`F(scA10ay{O&gfg*-4|NNR zb*6#7*A7^boFXoqAUtpX;9m9#`U<$tdR0x`kr>Qvb}%s;*UWx`hUFRqk1nG#;aeN+ z2b*hb_f1i9YdFA^x6ubtca*h}V{7$lR&xNdyMRyNEm`9QUSPAI)DF5=67*j6d9aeV zStnAlh@}kbzK%|PG41=+vw5?wX}uD=SIUbEXJt)U5rz=kM|bN1Vbt;nLjepgk;kfG zVz-i(AAe!Ufg*8LjmV~vXA|ETRzM(=mGiCD`xvOMyxej#f@`u=Fmx-+7|$)#m}t`R}PCUXcfWC1*@rd7`d=-AfIAf>8}(!|WB~ z5NQMyd_}p+cHmWhg_JQ0xNU`R-pMDIV1umjHfXJwXS7)=#lE}Uez11g7cXw|dZ$pB zGj@&P3NZTeWdfFaaAHQ?&{i;IyB1ZJub@_W>IjM}>H)7l>&SOjPIP$3N@LgD;KvKf z<})$e@1XFcPI7W;J-Q59$2GR%4na993KYl78T6}>$Mvu9m8m3{;y_B^k2AfzHRv7ditjPpb@fGMdRl37>;6rEJG?&l}C~0PY~T!g;o>V5|$qVk%b)On5c^eqBBC(Ziw4Y32M3FmclA>2lOxVt^)Ov&qOxXlWj5w+0dRpL?We^S=hX-YSe7Qg-S~2Wth{Tgy+iBbgt_fSXGIlevy) zgED^%;q|(Zz4K?_zy-(W(Z&-rlD`81Zx;1isVZL@k!@rbae@y|IuRb<9(biXrW@h4 zyY5{srwa>i0e3K*8ZbuojwqP|;UVrYeF_sWBD|s7vF-GP8&#?Kst{5Ba>lL2`&Nib zz>Affg)tlxBTe&++dv@*j zW4$c-BwE}0t+b#(Z)kP*S>F=9T#e*!eKjQmLlenH!U$1`W$X6feiN&*4v9x+Q_S)V zLJkT&U$=9e$lO20pGBN&Sf}A=G8yx}u4hVNA>ybX4U_Y!%NG4W8P!PHjAA67Sm2{?l4#zdwo_gHWAVjB=9zw z0@o;5=diEhoG|BqDBOYwwlOFj+e1jr5K8@dMkh0xL2FR`bb|~}+kIsD!a?Y9>{aXm zha3F%lDLs-PiO_Vm!5K5uDh>so$#7Jc(O z{V0s;{G1BJa;v;)^Xt|mleuroY>-Yc@cM^vm>J+k80;GdNME4lJvfS}8phzhe=t+Z zO4#JUe6ZP2r7y!%o6+i7p1U+3L~g(5z&4fzUqO9=c>l|%Jn{ze3ewQ3-bVxA*PAKLt7xtMl`-0*eacS)@Oh63C;_LRRVik=`EW1_MRF^MI@W@C1H3%t=UPJi4syBn&L1~Lv6j4sP6 zHq8s);P;&Zcu&<>3>m_XBn*0UtRznC=80t-*fv(K2Jfo1W_@Uz_0wvf|b;?BkSkSTo)&utiOzNL)sto%7&& zvbuHGDe)K5>u1ja7>fFIwfu%DbIO#5Zl`{FQ-Df!n&AO{Un7hlufyjsPQXUb?L{xc zw&<1}^A+9rQaS-O=|VV1*hrqlbo|w7%1_UGaEnBTN&Qx3ZcWk5udbO*O)po_vZ~Ey z31N@;_01yjq-<0jIScD}eo^EBM*#}*xd1i(jB95shJ#J)#V(^9%;*|i``IayV*525 zCX<>?Dp{?*4^L;r|8F7i*z!Ti%G--N_!gISS!1(m>!ok5-ZyVQS|TZ*f~)j*Fd)}D zlz#tNE54{(M-4+bU+sBLzJYG1Xz6{U-QUbAaG30BM~BXkH}uX0XYz+`zP|~fTT_4P zD}}Ei?7_*1R{hl=LTWI#7n``FKz@)g;WE#WL_~wzyYI=gtSrkbPY$aI*b5!-?jp=U zh8Z}_@_ibA&B7lF1HT^3aH~JbN+I=NoDo;0_%eco>Mh6<{s>-5sT?~JuDKd_8ZB)1 zvb*T&ckmG_Ye0qM;*geku;rEC%mr*>4_zCPvKq%?3^Lxsv1o*6UEI55OnIjbR;beYlO~v1Z5EF8(rd2P1@JkxGs{1J#Wal*Jcv@oqFU1~t4Pp9r-f4m)v_ztJck-HG3udKj=GEp;n^~>Fb+=~h zLDgLR;7e8-9ME~{5a!;i;6;%Vg6KxhjQ%g&m)0j7nXpOp{B7L|v~b+|U4P6*Wx6%M zuJk}hZV?9KP?)lF>0kU&D3N&&Du>s@@nq%3ZF_`ID#?Xp4mu{SEKhQ)%LBgYEqoe| zu{)c5N+o5I@)i4WgOT`4|9yP0SsbdS+M!m=HfO_KEGms9Nt z=n+~mn2HoRz|u5)I@BkCy)wjreGpOu&na7!oiIFQWY(tg2FckNTD)o-D;blV5Sf}} z)tcYrmJyYye~!QVTt|%*9ZvVORk8@IIV*H!S=`f$lNm-*eD$5^{ zAea>vRkLcn%{WQ5USv&{bRIBwTO;Bil(^Mm(IU!X`i9)AlUR`4}95##7Z zDD_N3t3=x?s0(QSoipMgUb#jbB1e-8Ol5+?Q4Q!p?28obr4a&vmxXE{^BR;|B22-b`AIloxId2B3+FP9_v29o{lw+rF8!?c-jj(r2jTRfu@-*;Rf&Ybht7tuR zUHH<;64GCt<4=>ommPczZ5a*D^EYL_$QTU%=xWk(dOhDDfi1;M6{0c9IryCmN;#%L zy78FF{jI{^Gd$xs;a?C50LS<>e#kkgt2w-;No{2(a>o{EY2H zn8oq?uJ}t zt+niyKb~YJu=9cG04C>K5HriK`1$tKhpZ&>*Q8G;_!9wJAyONW3WzKRB^{5IgPDv# z__utS7t*)ZPXpCwuQOUEI^{IJM5+KuttZTO_41c=&5m@KI*%Dcu?kFdc+J z^QdzD~`T2>8u#zi>k})cXYJM zVeKo`8?zL+LFj^ngL~3X$T-9jij#HVnKnbZ{+|zI9Ods{2zZ}GA8zsMSKA_@;ygjK zD9iKzosfO%4l8fv_^J;|Zz}{rP*kG#f57=GT90HWIhOgxDIn1?NvJD7zZP4WcK&h% zo1rqz#H-NT)6B)D-jgtsklCJ-o=lZ1!f}XQVUM8;CvbOP1luty88V=lG;Xa;Q7clz=Cp1V zcy3^bdh4ilrA>s(1%_wW`c6JzJn}mVHIDcjP?`u>ePuvltF96)J{c8^*%HnZY8*b% zya+>#5nnpa^Pun~8^%ccoqD0yL25FWIu+Xx3#Ee;+2@@5^HfSp{9P z{UbT!2j&lSrzCZ`6O;jcNlOwG5NpA-##m!X8mbH%JJM$uuuUlPHhoxbS)h{2Y8%>W zdGcuWuD><9PEWE>Wj813vpE}ecdre4axyf(8 z?6p4r3#%Kx2&Vy?wD;35DrV`YvX<17rpVg{)dy9w!w>!l@~b4=iz=a*pYe6zwfIAw zWtB$QK-$b?qB;EfeYqw@(OeX2Hod@|A7ZI*B<3 z5B3g84y2(DVZ2C&`d3b~+?g;V0FENpcOATtOZM*akgAfsU=i}>Fm+N!OvS0f^X|#u zT7Ao4+5TX=M2A4PIPuSwuDiTT)BHV{6Wv4Mn58X8Yo?xh7g;_w!fhMIK4(lwjqHC% z7LfnV?3ZeSC_y#EVj`ldh*D#@mVc~GE(sve82{A25i=)Em5)^y{XKb|Q8pCw&4Rx6 zxp*}On^kU@?ZE2SkDOWrM|E!c!(xr{3Yn_(v6p`%r!L?A)~Ik~;#;L$aMkP3?JU}t zfos`H4T=rQ1q8_(LrV5myplUZr9Q@qMcIq7V~GX`P|* zs2jFL%Y#?^h*f|5r9XtSAi(_?5C1*&pM3rUC<$-F13mw*@F?fdBn=!0eZ-eYgw%2X$4`O#lD@ literal 0 HcmV?d00001 diff --git a/templates/compose/executor.yaml b/templates/compose/executor.yaml new file mode 100644 index 0000000000..e6913ae5d7 --- /dev/null +++ b/templates/compose/executor.yaml @@ -0,0 +1,35 @@ +# documentation: https://executor.sh/docs/hosted/docker +# slogan: Self-hosted AI agent integrations and code execution server. +# category: development +# tags: executor,ai,agents,mcp,automation,development +# logo: svgs/executor.png +# port: 4788 + +services: + executor: + image: 'ghcr.io/rhyssullivan/executor-selfhost:${EXECUTOR_VERSION:-latest}' + environment: + - SERVICE_URL_EXECUTOR_4788 + - EXECUTOR_WEB_BASE_URL=${SERVICE_URL_EXECUTOR_4788} + - EXECUTOR_HOST=${EXECUTOR_HOST:-0.0.0.0} + - PORT=${PORT:-4788} + - EXECUTOR_DATA_DIR=/data + - EXECUTOR_DB_PATH=${EXECUTOR_DB_PATH:-/data/data.db} + - EXECUTOR_ORG_NAME=${EXECUTOR_ORG_NAME:-Default} + - EXECUTOR_ORG_SLUG=${EXECUTOR_ORG_SLUG:-default} + - EXECUTOR_ALLOW_LOCAL_NETWORK=${EXECUTOR_ALLOW_LOCAL_NETWORK:-false} + volumes: + - executor-data:/data + healthcheck: + test: + - CMD + - bun + - -e + - "fetch(`http://127.0.0.1:${process.env.PORT||4788}/api/health`).then(r=>process.exit(r.ok?0:1),()=>process.exit(1))" + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s + +volumes: + executor-data: diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index 00243fff09..b6a452e750 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -1400,6 +1400,24 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, + "executor": { + "documentation": "https://executor.sh/docs/hosted/docker?utm_source=coolify.io", + "slogan": "Self-hosted AI agent integrations and code execution server.", + "compose": "c2VydmljZXM6CiAgZXhlY3V0b3I6CiAgICBpbWFnZTogJ2doY3IuaW8vcmh5c3N1bGxpdmFuL2V4ZWN1dG9yLXNlbGZob3N0OiR7RVhFQ1VUT1JfVkVSU0lPTjotbGF0ZXN0fScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0VYRUNVVE9SXzQ3ODgKICAgICAgLSAnRVhFQ1VUT1JfV0VCX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfRVhFQ1VUT1JfNDc4OH0nCiAgICAgIC0gJ0VYRUNVVE9SX0hPU1Q9JHtFWEVDVVRPUl9IT1NUOi0wLjAuMC4wfScKICAgICAgLSAnUE9SVD0ke1BPUlQ6LTQ3ODh9JwogICAgICAtIEVYRUNVVE9SX0RBVEFfRElSPS9kYXRhCiAgICAgIC0gJ0VYRUNVVE9SX0RCX1BBVEg9JHtFWEVDVVRPUl9EQl9QQVRIOi0vZGF0YS9kYXRhLmRifScKICAgICAgLSAnRVhFQ1VUT1JfT1JHX05BTUU9JHtFWEVDVVRPUl9PUkdfTkFNRTotRGVmYXVsdH0nCiAgICAgIC0gJ0VYRUNVVE9SX09SR19TTFVHPSR7RVhFQ1VUT1JfT1JHX1NMVUc6LWRlZmF1bHR9JwogICAgICAtICdFWEVDVVRPUl9BTExPV19MT0NBTF9ORVRXT1JLPSR7RVhFQ1VUT1JfQUxMT1dfTE9DQUxfTkVUV09SSzotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnZXhlY3V0b3ItZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBidW4KICAgICAgICAtICctZScKICAgICAgICAtICdmZXRjaChgaHR0cDovLzEyNy4wLjAuMToke3Byb2Nlc3MuZW52LlBPUlR8fDQ3ODh9L2FwaS9oZWFsdGhgKS50aGVuKHI9PnByb2Nlc3MuZXhpdChyLm9rPzA6MSksKCk9PnByb2Nlc3MuZXhpdCgxKSknCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQogICAgICBzdGFydF9wZXJpb2Q6IDIwcwp2b2x1bWVzOgogIGV4ZWN1dG9yLWRhdGE6IG51bGwK", + "tags": [ + "executor", + "ai", + "agents", + "mcp", + "automation", + "development" + ], + "category": "development", + "logo": "svgs/executor.png", + "minversion": "0.0.0", + "template_last_updated_at": null, + "port": "4788" + }, "faraday": { "documentation": "https://faradaysec.com/?utm_source=coolify.io", "slogan": "Faraday is a powerful, open-source, web-based vulnerability management tool.", diff --git a/templates/service-templates.json b/templates/service-templates.json index c193f0934a..d279a6a33d 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -1400,6 +1400,24 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, + "executor": { + "documentation": "https://executor.sh/docs/hosted/docker?utm_source=coolify.io", + "slogan": "Self-hosted AI agent integrations and code execution server.", + "compose": "c2VydmljZXM6CiAgZXhlY3V0b3I6CiAgICBpbWFnZTogJ2doY3IuaW8vcmh5c3N1bGxpdmFuL2V4ZWN1dG9yLXNlbGZob3N0OiR7RVhFQ1VUT1JfVkVSU0lPTjotbGF0ZXN0fScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9FWEVDVVRPUl80Nzg4CiAgICAgIC0gJ0VYRUNVVE9SX1dFQl9CQVNFX1VSTD0ke1NFUlZJQ0VfRlFETl9FWEVDVVRPUl80Nzg4fScKICAgICAgLSAnRVhFQ1VUT1JfSE9TVD0ke0VYRUNVVE9SX0hPU1Q6LTAuMC4wLjB9JwogICAgICAtICdQT1JUPSR7UE9SVDotNDc4OH0nCiAgICAgIC0gRVhFQ1VUT1JfREFUQV9ESVI9L2RhdGEKICAgICAgLSAnRVhFQ1VUT1JfREJfUEFUSD0ke0VYRUNVVE9SX0RCX1BBVEg6LS9kYXRhL2RhdGEuZGJ9JwogICAgICAtICdFWEVDVVRPUl9PUkdfTkFNRT0ke0VYRUNVVE9SX09SR19OQU1FOi1EZWZhdWx0fScKICAgICAgLSAnRVhFQ1VUT1JfT1JHX1NMVUc9JHtFWEVDVVRPUl9PUkdfU0xVRzotZGVmYXVsdH0nCiAgICAgIC0gJ0VYRUNVVE9SX0FMTE9XX0xPQ0FMX05FVFdPUks9JHtFWEVDVVRPUl9BTExPV19MT0NBTF9ORVRXT1JLOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICdleGVjdXRvci1kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGJ1bgogICAgICAgIC0gJy1lJwogICAgICAgIC0gJ2ZldGNoKGBodHRwOi8vMTI3LjAuMC4xOiR7cHJvY2Vzcy5lbnYuUE9SVHx8NDc4OH0vYXBpL2hlYWx0aGApLnRoZW4ocj0+cHJvY2Vzcy5leGl0KHIub2s/MDoxKSwoKT0+cHJvY2Vzcy5leGl0KDEpKScKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgICAgIHN0YXJ0X3BlcmlvZDogMjBzCnZvbHVtZXM6CiAgZXhlY3V0b3ItZGF0YTogbnVsbAo=", + "tags": [ + "executor", + "ai", + "agents", + "mcp", + "automation", + "development" + ], + "category": "development", + "logo": "svgs/executor.png", + "minversion": "0.0.0", + "template_last_updated_at": null, + "port": "4788" + }, "faraday": { "documentation": "https://faradaysec.com/?utm_source=coolify.io", "slogan": "Faraday is a powerful, open-source, web-based vulnerability management tool.", diff --git a/tests/Unit/ExecutorServiceTemplateTest.php b/tests/Unit/ExecutorServiceTemplateTest.php new file mode 100644 index 0000000000..7e3d0ce50c --- /dev/null +++ b/tests/Unit/ExecutorServiceTemplateTest.php @@ -0,0 +1,49 @@ +toBeFile(); + + $compose = file_get_contents($templatePath); + + expect($compose) + ->toContain('ghcr.io/rhyssullivan/executor-selfhost:${EXECUTOR_VERSION:-latest}') + ->toContain('SERVICE_URL_EXECUTOR_4788') + ->toContain('EXECUTOR_WEB_BASE_URL=${SERVICE_URL_EXECUTOR_4788}') + ->toContain('executor-data:/data') + ->toContain('http://127.0.0.1:${process.env.PORT||4788}/api/health'); + + foreach (['service-templates.json', 'service-templates-latest.json'] as $templateFile) { + $templateCatalogPath = __DIR__."/../../templates/{$templateFile}"; + $templateCatalog = file_get_contents($templateCatalogPath); + + if ($templateCatalog === false) { + throw new RuntimeException("Unable to read service template catalog at {$templateCatalogPath}."); + } + + $templates = json_decode( + $templateCatalog, + associative: true, + flags: JSON_THROW_ON_ERROR, + ); + + expect($templates)->toHaveKey('executor'); + expect($templates['executor']['port'] ?? null)->toBe('4788'); + expect($templates['executor']['logo'] ?? null)->toBe('svgs/executor.png'); + expect($templates['executor']['category'] ?? null)->toBe('development'); + + $generatedCompose = base64_decode($templates['executor']['compose'], strict: true); + + expect($generatedCompose) + ->toContain('ghcr.io/rhyssullivan/executor-selfhost:${EXECUTOR_VERSION:-latest}') + ->toContain('http://127.0.0.1:${process.env.PORT||4788}/api/health') + ->toContain($templateFile === 'service-templates.json' + ? 'EXECUTOR_WEB_BASE_URL=${SERVICE_FQDN_EXECUTOR_4788}' + : 'EXECUTOR_WEB_BASE_URL=${SERVICE_URL_EXECUTOR_4788}'); + } +}); + +it('ships the Executor service icon from the public path used by the service picker', function () { + expect(__DIR__.'/../../public/svgs/executor.png')->toBeFile(); +}); From 7b7d5a0aad5feb37dfee42fc6575d9f1b141b17b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:45:26 +0200 Subject: [PATCH 21/53] fix(services): add CDN and default logo fallbacks --- app/Livewire/GlobalSearch.php | 3 +-- app/Livewire/Project/New/Select.php | 26 +------------------ bootstrap/helpers/services.php | 25 ++++++++++++++++++ .../views/livewire/global-search.blade.php | 4 ++- .../livewire/project/new/select.blade.php | 11 ++------ .../ServiceTemplatesLastUpdatedHintTest.php | 23 ++++++++++++++-- 6 files changed, 53 insertions(+), 39 deletions(-) diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index bf64ee8e9d..c6ed818e97 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -1507,8 +1507,7 @@ class GlobalSearch extends Component 'type' => 'one-click-service-'.$serviceKey, 'category' => 'Services', 'resourceType' => 'service', - 'logo' => data_get($service, 'logo'), - ] + array_filter([ + ] + service_logo_urls(data_get($service, 'logo')) + array_filter([ 'amd_only' => data_get($service, 'amd_only') ? true : null, 'arm_only' => data_get($service, 'arm_only') ? true : null, ])); diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php index 4cffff8001..ba03041e2b 100644 --- a/app/Livewire/Project/New/Select.php +++ b/app/Livewire/Project/New/Select.php @@ -111,38 +111,14 @@ class Select extends Component $templateLastUpdatedMap = $this->serviceTemplateLastUpdatedMap($services); $services = collect($services)->map(function ($service, $key) use ($templateLastUpdatedMap) { - $default_logo = 'svgs/default.webp'; - $logo = data_get($service, 'logo'); - - if (is_string($logo) && str_starts_with($logo, 'svg/')) { - $normalizedLogo = 'svgs/'.str($logo)->after('svg/'); - if (file_exists(public_path($normalizedLogo))) { - $logo = $normalizedLogo; - } - } - - $hasLogo = is_string($logo) - && basename($logo) !== basename($default_logo) - && file_exists(public_path($logo)); - - if (! $hasLogo) { - $logo = $default_logo; - } - - $local_logo_path = public_path($logo); $serviceKey = (string) $key; return [ 'id' => $serviceKey, 'name' => str($serviceKey)->headline(), 'docsSlug' => str($serviceKey)->lower()->value(), - 'has_logo' => $hasLogo, - 'logo' => asset($logo), - 'logo_github_url' => file_exists($local_logo_path) - ? 'https://raw.githubusercontent.com/coollabsio/coolify/refs/heads/main/public/'.$logo - : asset($default_logo), 'templateLastUpdated' => $templateLastUpdatedMap[$serviceKey] ?? null, - ] + (array) $service; + ] + service_logo_urls(data_get($service, 'logo')) + (array) $service; })->all(); // Extract unique categories from services diff --git a/bootstrap/helpers/services.php b/bootstrap/helpers/services.php index 07fdeb086f..96257a6323 100644 --- a/bootstrap/helpers/services.php +++ b/bootstrap/helpers/services.php @@ -1,5 +1,30 @@ asset($defaultLogo), + 'logo_cdn_url' => asset($defaultLogo), + 'logo_default_url' => asset($defaultLogo), + ]; + } + + if (str_starts_with($logo, 'svg/')) { + $logo = 'svgs/'.str($logo)->after('svg/'); + } + + $logo = ltrim($logo, '/'); + + return [ + 'logo' => asset($logo), + 'logo_cdn_url' => 'https://raw.githubusercontent.com/coollabsio/coolify/refs/heads/main/public/'.$logo, + 'logo_default_url' => asset($defaultLogo), + ]; +} + use App\Models\Application; use App\Models\Service; use App\Models\ServiceApplication; diff --git a/resources/views/livewire/global-search.blade.php b/resources/views/livewire/global-search.blade.php index 43d6e6b55a..9e714b59f9 100644 --- a/resources/views/livewire/global-search.blade.php +++ b/resources/views/livewire/global-search.blade.php @@ -655,7 +655,9 @@ class="search-result-item command-palette-item"> - - diff --git a/tests/Feature/ResourceIndexDescriptionSubtitleTest.php b/tests/Feature/ResourceIndexDescriptionSubtitleTest.php index ab570b7f34..eec80be0a2 100644 --- a/tests/Feature/ResourceIndexDescriptionSubtitleTest.php +++ b/tests/Feature/ResourceIndexDescriptionSubtitleTest.php @@ -9,3 +9,12 @@ test('resource table subtitle shows description only and never falls back to uui ->not->toContain('item.description || item.fqdn || item.uuid') ->not->toContain('item.fqdn || item.uuid'); }); + +test('resource grid does not show a placeholder when a domain is missing', function () { + $view = file_get_contents(resource_path('views/livewire/project/resource/index.blade.php')); + $gridView = explode('x-show="viewMode === \'grid\'"', $view, 2)[1]; + + expect($gridView) + ->not->toContain('x-show="!item.fqdn"') + ->not->toContain('>-'); +}); From c97649bda2d52e4079ae13a9d03229c700448777 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:57:29 +0200 Subject: [PATCH 39/53] docs(security): require scoped authorization checks and regression tests --- AGENTS.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 34b43e0876..f9f8ca563a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,6 +148,11 @@ Because the "server" and the test share one PHP process, they share the phpunit - Custom gates: `createAnyResource`, `canAccessTerminal` - Role hierarchy: `Role::MEMBER` (1) < `Role::ADMIN` (2) < `Role::OWNER` (3) with `lt()`/`gt()` comparison methods - Multi-tenancy via Teams — team auto-initializes notification settings on creation +- Authorize every server-side read and mutation where access can vary by user, role, team, or resource. Use policies, gates, or `$this->authorize(...)`; never rely on hidden Blade/Livewire controls such as `@can` for security. +- Scope queries to the current team before returning records. Treat route and model identifiers as untrusted, and prevent users from reading or changing resources owned by another team. +- Apply authorization consistently across Livewire actions, API and web controllers, actions, downloads, exports, search, event listeners, and any other path that exposes or changes protected data. +- Default to denying access when a policy or ownership relationship is missing or ambiguous. Members must not gain access to administrative, credential, security, billing, or instance-wide data merely because they belong to the team. +- Add authorization regression tests for protected changes. Cover permitted access, member restrictions where applicable, and cross-team access; verify unauthorized reads and writes return `403` or otherwise reveal no protected data. ### Event Broadcasting - Soketi WebSocket server for real-time updates (ports 6001-6002 in dev) From ff8b019296025bdf0e8a08c1989b270193edddc4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:12:28 +0200 Subject: [PATCH 40/53] fix(application): defer Docker Compose loading and preserve domains Load the compose file only after the user confirms its location, initialize the default compose path when switching build packs, and retain existing application domains. --- app/Livewire/Project/Application/General.php | 22 ++---------- .../project/application/general.blade.php | 3 +- ...pplicationGeneralBuildpackSelectorTest.php | 35 +++++++++++++++++++ .../Feature/ApplicationGeneralLayoutTest.php | 8 +++++ 4 files changed, 47 insertions(+), 21 deletions(-) diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php index c5015dccc0..0c424631f7 100644 --- a/app/Livewire/Project/Application/General.php +++ b/app/Livewire/Project/Application/General.php @@ -321,17 +321,6 @@ class General extends Component } } $this->initialDockerComposeLocation = $this->application->docker_compose_location; - if ($this->application->build_pack === 'dockercompose' && ! $this->application->docker_compose_raw) { - // Only load compose file if user has update permission - try { - $this->authorize('update', $this->application); - $this->initLoadingCompose = true; - $this->dispatch('info', 'Loading docker compose file.'); - } catch (AuthorizationException $e) { - // User doesn't have update permission, skip loading compose file - } - } - if (str($this->application->status)->startsWith('running') && is_null($this->application->config_hash)) { $this->dispatch('configurationChanged'); } @@ -608,14 +597,9 @@ class General extends Component $this->resetDefaultLabels(false); } if ($this->buildPack === 'dockercompose') { - // Only update if user has permission - try { - $this->authorize('update', $this->application); - $this->fqdn = null; - $this->application->fqdn = null; - $this->application->settings->save(); - } catch (AuthorizationException $e) { - // User doesn't have update permission, just continue without saving + if (blank($this->dockerComposeLocation)) { + $this->dockerComposeLocation = '/docker-compose.yaml'; + $this->application->docker_compose_location = $this->dockerComposeLocation; } } if ($this->buildPack === 'static') { diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index 7384067f98..d301b55b5a 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -119,8 +119,7 @@ @else

@if ($buildPack === 'dockercompose') -
+
not->toContain('x-init="$wire.dispatch(\'loadCompose\', true)"'); +}); + test('docker compose heading separates its title and action', function () { $view = file_get_contents(resource_path('views/livewire/project/application/general.blade.php')); From e4146a631409c18083718a13022ca093751dc0ee Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 31 Aug 2026 22:51:30 +0200 Subject: [PATCH 41/53] feat(backups): unify service backup management (#11574) --- app/Jobs/DatabaseBackupJob.php | 11 +- .../Project/Database/BackupExecutions.php | 7 +- .../Project/Service/BackupExecutions.php | 124 +++++++ .../Project/Service/DatabaseBackups.php | 23 +- app/Livewire/Project/Service/ImportBackup.php | 80 +++++ app/Livewire/Project/Service/Index.php | 6 - .../Project/Service/VolumeBackup/Index.php | 85 ++++- .../Project/Service/VolumeBackup/Show.php | 6 +- resources/css/app.css | 21 +- .../views/components/backup-sidebar.blade.php | 10 +- .../views/components/backup-tabs.blade.php | 62 ++++ .../service-database/sidebar.blade.php | 18 - .../service/configuration-sidebar.blade.php | 7 +- .../service/backup-executions.blade.php | 64 ++++ .../project/service/configuration.blade.php | 3 +- .../service/database-backups.blade.php | 47 ++- .../project/service/import-backup.blade.php | 39 +++ .../livewire/project/service/index.blade.php | 2 +- .../service/volume-backup/index.blade.php | 81 ++++- .../service/volume-backup/show.blade.php | 24 +- routes/web.php | 5 +- tests/Feature/ImportBackupUploadIconTest.php | 6 +- ...bleLivewireComponentsAuthorizationTest.php | 14 + .../ServiceDatabaseVerticalNavigationTest.php | 144 +++++++- tests/Feature/ServiceResourceRoutingTest.php | 307 +++++++++++++++--- tests/Feature/VolumeBackupTest.php | 10 + 26 files changed, 1066 insertions(+), 140 deletions(-) create mode 100644 app/Livewire/Project/Service/BackupExecutions.php create mode 100644 app/Livewire/Project/Service/ImportBackup.php create mode 100644 resources/views/components/backup-tabs.blade.php create mode 100644 resources/views/livewire/project/service/backup-executions.blade.php create mode 100644 resources/views/livewire/project/service/import-backup.blade.php diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 0b73ed0cf5..b1f2c38b96 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -322,6 +322,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue 'scheduled_database_backup_id' => $this->backup->id, 'local_storage_deleted' => false, ]); + BackupCreated::dispatch($this->team->id); $this->backup_standalone_postgresql($database); } elseif (str($databaseType)->contains('mongo')) { if ($database === '*') { @@ -343,6 +344,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue 'scheduled_database_backup_id' => $this->backup->id, 'local_storage_deleted' => false, ]); + BackupCreated::dispatch($this->team->id); $this->backup_standalone_mongodb($database); } elseif (str($databaseType)->contains('mysql')) { $this->backup_file = "/mysql-dump-$database-".Carbon::now()->timestamp.'.dmp'; @@ -357,6 +359,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue 'scheduled_database_backup_id' => $this->backup->id, 'local_storage_deleted' => false, ]); + BackupCreated::dispatch($this->team->id); $this->backup_standalone_mysql($database); } elseif (str($databaseType)->contains('mariadb')) { $this->backup_file = "/mariadb-dump-$database-".Carbon::now()->timestamp.'.dmp'; @@ -371,6 +374,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue 'scheduled_database_backup_id' => $this->backup->id, 'local_storage_deleted' => false, ]); + BackupCreated::dispatch($this->team->id); $this->backup_standalone_mariadb($database); } elseif ($this->database instanceof StandaloneClickhouse) { $this->backup_file = '/clickhouse-backup-'.Carbon::now()->timestamp."-{$this->backup_log_uuid}.zip"; @@ -382,6 +386,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue 'scheduled_database_backup_id' => $this->backup->id, 'local_storage_deleted' => false, ]); + BackupCreated::dispatch($this->team->id); $this->backup_standalone_clickhouse($database); } else { throw new \Exception('Unsupported database type'); @@ -480,14 +485,14 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } catch (Throwable $e) { throw $e; } finally { - if ($this->team) { - BackupCreated::dispatch($this->team->id); - } if ($this->backup_log) { $this->backup_log->update([ 'finished_at' => Carbon::now()->toImmutable(), ]); } + if ($this->team) { + BackupCreated::dispatch($this->team->id); + } } } diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index 73877a945e..2786a45c3d 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -6,7 +6,6 @@ use App\Models\ScheduledDatabaseBackup; use App\Models\ServiceDatabase; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Auth; use Livewire\Component; class BackupExecutions extends Component @@ -37,12 +36,12 @@ class BackupExecutions extends Component public $delete_backup_sftp = false; - public function getListeners() + public function getListeners(): array { - $userId = Auth::id(); + $teamId = currentTeam()->id; return [ - "echo-private:team.{$userId},BackupCreated" => 'refreshBackupExecutions', + "echo-private:team.{$teamId},BackupCreated" => 'refreshBackupExecutions', ]; } diff --git a/app/Livewire/Project/Service/BackupExecutions.php b/app/Livewire/Project/Service/BackupExecutions.php new file mode 100644 index 0000000000..87f24fb1da --- /dev/null +++ b/app/Livewire/Project/Service/BackupExecutions.php @@ -0,0 +1,124 @@ +id; + + return [ + 'modalClosed' => 'closeExecutionModal', + "echo-private:team.{$teamId},BackupCreated" => '$refresh', + ]; + } + + public function mount(Service $service): void + { + abort_unless($service->environment?->project?->team_id === currentTeam()->id, 404); + $this->service = $service; + $this->authorize('view', $this->service); + } + + public function openExecution(string $executionUuid): void + { + $this->selectedExecution = $this->executions()->firstWhere('uuid', $executionUuid); + abort_unless($this->selectedExecution, 404); + $this->executionModalOpen = true; + } + + public function closeExecutionModal(): void + { + $this->executionModalOpen = false; + $this->selectedExecution = null; + } + + public function render(): View + { + return view('livewire.project.service.backup-executions', [ + 'executions' => $this->executions(), + ]); + } + + private function executions(): Collection + { + $databaseScheduleIds = ScheduledDatabaseBackup::query() + ->where('database_type', (new ServiceDatabase)->getMorphClass()) + ->whereHasMorph('database', [ServiceDatabase::class], fn ($query) => $query->where('service_id', $this->service->id)) + ->pluck('id'); + + $databaseExecutions = ScheduledDatabaseBackupExecution::query() + ->with('scheduledDatabaseBackup.database') + ->whereIn('scheduled_database_backup_id', $databaseScheduleIds) + ->latest() + ->limit(100) + ->get() + ->map(fn (ScheduledDatabaseBackupExecution $execution): array => [ + 'id' => 'database:'.$execution->id, + 'uuid' => $execution->uuid, + 'target' => $execution->scheduledDatabaseBackup->database->human_name ?: $execution->scheduledDatabaseBackup->database->name, + 'type' => 'Database', + 'schedule' => $execution->scheduledDatabaseBackup->frequency, + 'status' => $execution->status, + 'started_at' => $execution->created_at, + 'size' => $execution->size, + 'message' => $execution->message, + 'filename' => $execution->filename, + 'download_url' => $execution->status === 'success' && ! $execution->local_storage_deleted + ? route('download.backup', $execution->id) + : null, + ]); + + $volumeSchedules = ScheduledVolumeBackup::query() + ->with('backupable.resource') + ->forService($this->service) + ->get() + ->keyBy('id'); + $volumeExecutions = ScheduledVolumeBackupExecution::query() + ->whereIn('scheduled_volume_backup_id', $volumeSchedules->keys()) + ->latest() + ->limit(100) + ->get() + ->map(function (ScheduledVolumeBackupExecution $execution) use ($volumeSchedules): array { + $schedule = $volumeSchedules->get($execution->scheduled_volume_backup_id); + + return [ + 'id' => 'storage:'.$execution->id, + 'uuid' => $execution->uuid, + 'target' => $schedule->targetName(), + 'type' => $schedule->targetType(), + 'schedule' => $schedule->frequency, + 'status' => $execution->status, + 'started_at' => $execution->created_at, + 'size' => $execution->size, + 'message' => $execution->message, + 'filename' => $execution->filename, + 'download_url' => $execution->status === 'success' && ! $execution->local_storage_deleted + ? route('download.volume-backup', $execution->id) + : null, + ]; + }); + + return $databaseExecutions->concat($volumeExecutions)->sortByDesc('started_at')->values(); + } +} diff --git a/app/Livewire/Project/Service/DatabaseBackups.php b/app/Livewire/Project/Service/DatabaseBackups.php index 90907abc6e..8535584dd8 100644 --- a/app/Livewire/Project/Service/DatabaseBackups.php +++ b/app/Livewire/Project/Service/DatabaseBackups.php @@ -22,8 +22,6 @@ class DatabaseBackups extends Component public array $query; - public bool $isImportSupported = false; - public ?ScheduledDatabaseBackup $backup = null; public string $section = 'index'; @@ -32,7 +30,7 @@ class DatabaseBackups extends Component protected $listeners = ['refreshScheduledBackups' => '$refresh']; - public function mount() + public function mount(): mixed { try { $this->parameters = array_filter( @@ -67,10 +65,13 @@ class DatabaseBackups extends Component return redirect()->route('project.service.index', $this->parameters); } - // Check if import is supported for this database type - $dbType = $this->serviceDatabase->databaseType(); - $supportedTypes = ['mysql', 'mariadb', 'postgres', 'mongo']; - $this->isImportSupported = collect($supportedTypes)->contains(fn ($type) => str_contains($dbType, $type)); + if (! request()->route('backup_uuid')) { + return redirect()->route('project.service.volume-backups.index', [ + 'project_uuid' => $this->parameters['project_uuid'], + 'environment_uuid' => $this->parameters['environment_uuid'], + 'service_uuid' => $this->parameters['service_uuid'], + ]); + } if (request()->route('backup_uuid')) { $this->backup = $this->serviceDatabase->scheduledBackups() @@ -85,6 +86,14 @@ class DatabaseBackups extends Component 'project.service.database.backup.danger' => 'danger', default => 'general', }; + + $routeParameters = [ + 'project_uuid' => $this->parameters['project_uuid'], + 'environment_uuid' => $this->parameters['environment_uuid'], + 'service_uuid' => $this->parameters['service_uuid'], + ]; + + return redirect()->route('project.service.volume-backups.index', $routeParameters); } } catch (\Throwable $e) { return handleError($e, $this); diff --git a/app/Livewire/Project/Service/ImportBackup.php b/app/Livewire/Project/Service/ImportBackup.php new file mode 100644 index 0000000000..29e8d9f359 --- /dev/null +++ b/app/Livewire/Project/Service/ImportBackup.php @@ -0,0 +1,80 @@ +parameters = get_route_parameters(); + $project = currentTeam()->projects()->whereUuid($this->parameters['project_uuid'])->firstOrFail(); + $environment = $project->environments()->whereUuid($this->parameters['environment_uuid'])->firstOrFail(); + $this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail(); + $this->authorize('update', $this->service); + + $this->databases = $this->service->databases + ->filter(fn (ServiceDatabase $database): bool => $this->supportsImport($database)) + ->values(); + + $databaseUuid = request()->route('stack_service_uuid'); + if ($databaseUuid) { + $selectedDatabase = $this->databases->firstWhere('uuid', $databaseUuid); + abort_unless($selectedDatabase instanceof ServiceDatabase, 404); + $this->authorize('update', $selectedDatabase); + $this->selectedDatabase = $selectedDatabase; + $this->selectedDatabaseUuid = $selectedDatabase->uuid; + + if (request()->routeIs('project.service.database.import')) { + return redirect()->route('project.service.import-backup.database', $this->parameters); + } + } elseif ($this->databases->count() === 1) { + return redirect()->route('project.service.import-backup.database', [ + ...$this->parameters, + 'stack_service_uuid' => $this->databases->first()->uuid, + ]); + } + + return null; + } + + public function updatedSelectedDatabaseUuid(): mixed + { + $database = $this->databases->firstWhere('uuid', $this->selectedDatabaseUuid); + abort_unless($database instanceof ServiceDatabase, 404); + $this->authorize('update', $database); + + return redirect()->route('project.service.import-backup.database', [ + ...$this->parameters, + 'stack_service_uuid' => $database->uuid, + ]); + } + + public function render(): View + { + return view('livewire.project.service.import-backup'); + } + + private function supportsImport(ServiceDatabase $database): bool + { + return str($database->databaseType())->contains(['mysql', 'mariadb', 'postgres', 'mongo']); + } +} diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php index 6193f14b5d..23ee6a0965 100644 --- a/app/Livewire/Project/Service/Index.php +++ b/app/Livewire/Project/Service/Index.php @@ -59,8 +59,6 @@ class Index extends Component public bool $isLogDrainEnabled = false; - public bool $isImportSupported = false; - // Application-specific properties public $docker_cleanup = true; @@ -153,10 +151,6 @@ class Index extends Component $this->refreshFileStorages(); $this->syncDatabaseData(false); - // Check if import is supported for this database type - $dbType = $this->serviceDatabase->databaseType(); - $supportedTypes = ['mysql', 'mariadb', 'postgres', 'mongo']; - $this->isImportSupported = collect($supportedTypes)->contains(fn ($type) => str_contains($dbType, $type)); } private function syncDatabaseData(bool $toModel = false): void diff --git a/app/Livewire/Project/Service/VolumeBackup/Index.php b/app/Livewire/Project/Service/VolumeBackup/Index.php index 49da9e21f4..e856d1373a 100644 --- a/app/Livewire/Project/Service/VolumeBackup/Index.php +++ b/app/Livewire/Project/Service/VolumeBackup/Index.php @@ -2,11 +2,14 @@ namespace App\Livewire\Project\Service\VolumeBackup; +use App\Jobs\DatabaseBackupJob; +use App\Jobs\VolumeBackupJob; use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledVolumeBackup; use App\Models\Service; use App\Models\ServiceDatabase; use Illuminate\Contracts\View\View; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Livewire\Component; @@ -20,14 +23,70 @@ class Index extends Component public string $search = ''; - protected $listeners = ['refreshVolumeBackups' => '$refresh']; + public bool $scheduleModalOpen = false; - public function mount(): void + public ?ScheduledDatabaseBackup $selectedDatabaseBackup = null; + + public ?ScheduledVolumeBackup $selectedVolumeBackup = null; + + public ?Collection $s3s = null; + + public function getListeners(): array { - $this->service = $this->findService(); + $teamId = currentTeam()->id; + + return [ + 'refreshVolumeBackups' => '$refresh', + 'modalClosed' => 'closeScheduleModal', + "echo-private:team.{$teamId},BackupCreated" => '$refresh', + ]; + } + + public function mount(?Service $service = null): void + { + $this->service = $service ?? $this->findService(); $this->authorize('view', $this->service); $this->parameters = get_route_parameters(); $this->search = request()->string('search')->toString(); + + } + + public function openSchedule(string $backupUuid): void + { + $this->loadSelectedSchedule($backupUuid); + $this->s3s = currentTeam()->s3s; + $this->scheduleModalOpen = true; + } + + public function closeScheduleModal(): void + { + $this->scheduleModalOpen = false; + $this->selectedDatabaseBackup = null; + $this->selectedVolumeBackup = null; + } + + public function backupNow(string $type, string $backupUuid): void + { + try { + if ($type === 'database') { + $this->loadSelectedSchedule($backupUuid); + abort_unless($this->selectedDatabaseBackup, 404); + $this->authorize('manageBackups', $this->selectedDatabaseBackup->database); + DatabaseBackupJob::dispatch($this->selectedDatabaseBackup); + } else { + abort_unless($type === 'storage', 404); + $this->loadSelectedSchedule($backupUuid); + abort_unless($this->selectedVolumeBackup, 404); + $this->authorize('update', $this->selectedVolumeBackup->targetResource()); + VolumeBackupJob::dispatch($this->selectedVolumeBackup); + } + + $this->selectedDatabaseBackup = null; + $this->selectedVolumeBackup = null; + $this->dispatch('success', 'Backup queued.'); + } catch (\Throwable $e) { + handleError($e, $this); + } } public function render(): View @@ -68,4 +127,24 @@ class Index extends Component ->where('uuid', request()->route('service_uuid')) ->firstOrFail(); } + + private function loadSelectedSchedule(string $backupUuid): void + { + $this->selectedDatabaseBackup = ScheduledDatabaseBackup::query() + ->with('database') + ->whereUuid($backupUuid) + ->where('database_type', (new ServiceDatabase)->getMorphClass()) + ->whereHasMorph('database', [ServiceDatabase::class], fn ($query) => $query->where('service_id', $this->service->id)) + ->first(); + + if ($this->selectedDatabaseBackup) { + return; + } + + $this->selectedVolumeBackup = ScheduledVolumeBackup::query() + ->with('backupable.resource') + ->whereUuid($backupUuid) + ->forService($this->service) + ->firstOrFail(); + } } diff --git a/app/Livewire/Project/Service/VolumeBackup/Show.php b/app/Livewire/Project/Service/VolumeBackup/Show.php index eec60497f7..10abeb3bf4 100644 --- a/app/Livewire/Project/Service/VolumeBackup/Show.php +++ b/app/Livewire/Project/Service/VolumeBackup/Show.php @@ -20,7 +20,7 @@ class Show extends Component public string $section = 'general'; - public function mount(): void + public function mount(): mixed { $project = currentTeam()->projects()->where('uuid', request()->route('project_uuid'))->firstOrFail(); $environment = $project->environments()->where('uuid', request()->route('environment_uuid'))->firstOrFail(); @@ -43,6 +43,10 @@ class Show extends Component 'project.service.volume-backups.danger' => 'danger', default => 'general', }; + + $routeParameters = collect($this->parameters)->except('backup_uuid')->all(); + + return redirect()->route('project.service.volume-backups.index', $routeParameters); } public function render(): View diff --git a/resources/css/app.css b/resources/css/app.css index b1ae74c09a..40af521c69 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -2521,8 +2521,25 @@ input[type="search"]::-webkit-search-results-decoration { } .service-backup-table-grid { - grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem 6.5rem minmax(8rem, 1fr); - min-width: 45rem; + grid-template-columns: minmax(10rem, 1.7fr) 6rem minmax(7rem, 0.8fr) 7.5rem 6.5rem minmax(8rem, 1fr) 7.5rem; + width: 100%; +} + +.data-table-row.service-backup-table-grid { + background: var(--coollabs-base); + border-bottom: 1px solid var(--coollabs-fill); +} + +.data-table-row.service-backup-table-grid:last-child { + border-bottom: 0; +} + +.data-table-row.service-backup-table-grid:hover { + background: color-mix(in srgb, var(--coollabs-base) 98%, black); +} + +.dark .data-table-row.service-backup-table-grid:hover { + background: color-mix(in srgb, var(--coollabs-base) 98%, white); } /* Persistent storage volumes: Name | Source | Destination | [PR suffix] | Backup | [Actions] */ diff --git a/resources/views/components/backup-sidebar.blade.php b/resources/views/components/backup-sidebar.blade.php index 90c86ddb55..a2177b690d 100644 --- a/resources/views/components/backup-sidebar.blade.php +++ b/resources/views/components/backup-sidebar.blade.php @@ -15,7 +15,7 @@ 'danger' => 'project.application.backup.danger', ], 'service' => [ - 'back' => 'project.service.database.backups', + 'back' => 'project.service.volume-backups.index', 'general' => 'project.service.database.backup.show', 's3' => 'project.service.database.backup.s3', 'retention' => 'project.service.database.backup.retention', @@ -48,9 +48,11 @@ ['key' => 'danger', 'label' => 'Danger Zone', 'icon' => 'shield-alert'], ]; $backLabel = $context === 'database' ? 'Back to database' : 'Back to backups'; - $backParameters = $context === 'database' - ? collect($parameters)->except('backup_uuid')->all() - : $parameters; + $backParameters = match ($context) { + 'database' => collect($parameters)->except('backup_uuid')->all(), + 'service' => collect($parameters)->except(['stack_service_uuid', 'backup_uuid'])->all(), + default => $parameters, + }; @endphp