From 1d1808664f2a23c87f0aa7cd7fcc9466e24a5075 Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Tue, 16 Jun 2026 18:36:06 +0200 Subject: [PATCH 01/13] chore: bump up timeouts --- app/Jobs/ProcessPolydockAppInstanceJobs/BaseJob.php | 4 ++-- config/horizon.php | 6 +++--- config/queue.php | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/Jobs/ProcessPolydockAppInstanceJobs/BaseJob.php b/app/Jobs/ProcessPolydockAppInstanceJobs/BaseJob.php index 1bb60e8d..e2e7bd24 100644 --- a/app/Jobs/ProcessPolydockAppInstanceJobs/BaseJob.php +++ b/app/Jobs/ProcessPolydockAppInstanceJobs/BaseJob.php @@ -28,7 +28,7 @@ abstract class BaseJob implements ShouldQueue * * @var int */ - public $timeout = 180; + public $timeout = 600; /** * Indicate if the job should fail when the timeout is exceeded. @@ -37,7 +37,7 @@ abstract class BaseJob implements ShouldQueue */ public $failOnTimeout = true; - protected const OVERLAP_LOCK_SECONDS = 200; + protected const OVERLAP_LOCK_SECONDS = 660; protected PolydockAppInstance $appInstance; diff --git a/config/horizon.php b/config/horizon.php index eb8d2034..d4bb0182 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -202,7 +202,7 @@ 'maxJobs' => 0, 'memory' => 256, 'tries' => 1, - 'timeout' => 180, + 'timeout' => env('HORIZON_SUPERVISOR_TIMEOUT', 600), 'nice' => 0, ], 'supervisor-2' => [ @@ -215,7 +215,7 @@ 'maxJobs' => 0, 'memory' => 256, 'tries' => 1, - 'timeout' => 180, + 'timeout' => env('HORIZON_SUPERVISOR_TIMEOUT', 600), 'nice' => 0, ], 'supervisor-3' => [ @@ -237,7 +237,7 @@ 'maxJobs' => 0, 'memory' => 256, 'tries' => 1, - 'timeout' => 180, + 'timeout' => env('HORIZON_SUPERVISOR_TIMEOUT', 600), ], ], diff --git a/config/queue.php b/config/queue.php index 411519dc..67a9323a 100644 --- a/config/queue.php +++ b/config/queue.php @@ -41,7 +41,7 @@ 'connection' => env('DB_QUEUE_CONNECTION'), 'table' => env('DB_QUEUE_TABLE', 'jobs'), 'queue' => env('DB_QUEUE', 'default'), - 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 240), + 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 660), 'after_commit' => false, ], @@ -69,7 +69,7 @@ 'driver' => 'redis', 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), 'queue' => env('REDIS_QUEUE', 'default'), - 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 240), + 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 660), 'block_for' => null, 'after_commit' => false, ], From 9e95d8968939453caf69886e73089ccf8f8d8fe8 Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Tue, 16 Jun 2026 21:06:59 +0200 Subject: [PATCH 02/13] chore: improve update metadata calls --- app/Console/Commands/SyncLagoonMetadata.php | 6 +- .../Commands/SyncLagoonMetadataTest.php | 63 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/app/Console/Commands/SyncLagoonMetadata.php b/app/Console/Commands/SyncLagoonMetadata.php index a4017477..992f2fb3 100644 --- a/app/Console/Commands/SyncLagoonMetadata.php +++ b/app/Console/Commands/SyncLagoonMetadata.php @@ -126,6 +126,7 @@ public function handle(): int ]); // Query Lagoon for current project details and metadata to check if changes exist + $projectId = null; $existingMetadata = []; try { $projectResponse = $client->getProjectByName($projectName); @@ -136,6 +137,9 @@ public function handle(): int continue; } + if (isset($projectData['id'])) { + $projectId = (int) $projectData['id']; + } if (! empty($projectData['metadata'])) { if (is_array($projectData['metadata'])) { $existingMetadata = $projectData['metadata']; @@ -156,7 +160,7 @@ public function handle(): int } try { - $result = $client->updateProjectMetadata($projectName, $key, (string) $value); + $result = $client->updateProjectMetadata($projectId ?? $projectName, $key, (string) $value); if (isset($result['error'])) { $errorMsg = is_array($result['error']) ? json_encode($result['error']) : $result['error']; diff --git a/tests/Feature/Console/Commands/SyncLagoonMetadataTest.php b/tests/Feature/Console/Commands/SyncLagoonMetadataTest.php index 4a733706..99ea857d 100644 --- a/tests/Feature/Console/Commands/SyncLagoonMetadataTest.php +++ b/tests/Feature/Console/Commands/SyncLagoonMetadataTest.php @@ -660,4 +660,67 @@ public function test_it_restricts_get_authenticated_client_overrides(): void $this->assertEquals('lagoon', $config['ssh_user'] ?? null); $this->assertEquals('https://api.lagoon.amazeeio.cloud/graphql', $config['endpoint'] ?? null); } + + public function test_it_passes_project_id_instead_of_name_when_id_is_returned_by_api(): void + { + $this->setupLagoonKey(); + + $store = PolydockStore::factory()->create(); + $storeApp = PolydockStoreApp::factory()->create([ + 'polydock_store_id' => $store->id, + ]); + + $instance = new PolydockAppInstance; + $instance->uuid = 'test-instance-id-override-uuid'; + $instance->polydock_store_app_id = $storeApp->id; + $instance->name = 'test-instance-id-override'; + $instance->status = PolydockAppInstanceStatus::RUNNING_HEALTHY_CLAIMED; + $instance->app_type = 'test_app_type'; + $instance->data = [ + 'lagoon-project-name' => 'project-override', + 'user-email' => 'id-override@example.com', + ]; + $instance->saveQuietly(); + + $mock = \Mockery::mock(Client::class); + $mock->shouldReceive('setLagoonToken')->with('fake-token')->once(); + $mock->shouldReceive('initGraphqlClient')->once(); + + // getProjectByName returns 'id' => 12345 + $mock->shouldReceive('getProjectByName') + ->with('project-override') + ->once() + ->andReturn([ + 'projectByName' => [ + 'id' => 12345, + 'metadata' => [], + ], + ]); + + // It should update metadata using the project ID (12345) instead of the project name ('project-override') + $mock->shouldReceive('updateProjectMetadata') + ->with(12345, 'email', 'id-override@example.com') + ->once() + ->andReturn(['id' => 1]); + + $mock->shouldReceive('updateProjectMetadata') + ->with(12345, 'product-type', 'generic') + ->once() + ->andReturn(['id' => 1]); + + $mock->shouldReceive('updateProjectMetadata') + ->with(12345, 'polydock-env', 'dev') + ->once() + ->andReturn(['id' => 1]); + + $this->app->instance(Client::class, $mock); + + $this->artisan('polydock:sync-metadata', [ + '--uuid' => 'test-instance-id-override-uuid', + '--force' => true, + ]) + ->expectsOutput('Found 1 active app instance(s) to sync.') + ->expectsOutput('Syncing metadata for test-instance-id-override (Project: project-override)...') + ->assertExitCode(0); + } } From 6dd38524e16df61ee7a59141edc7713233f9e261 Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Tue, 16 Jun 2026 21:17:34 +0200 Subject: [PATCH 03/13] chore: update deps --- composer.lock | 10 +-- package-lock.json | 167 ++++++++++++++++++++++++++++++++++------------ 2 files changed, 130 insertions(+), 47 deletions(-) diff --git a/composer.lock b/composer.lock index 33a73c44..25cc9889 100644 --- a/composer.lock +++ b/composer.lock @@ -10345,16 +10345,16 @@ }, { "name": "laravel/pint", - "version": "v1.29.2", + "version": "v1.29.3", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "88493e9b15581b73db00fcc3cedfec07bcc52cf5" + "reference": "da1d1111a6aa2e082d2a388b194afe1ba0a05d14" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/88493e9b15581b73db00fcc3cedfec07bcc52cf5", - "reference": "88493e9b15581b73db00fcc3cedfec07bcc52cf5", + "url": "https://api.github.com/repos/laravel/pint/zipball/da1d1111a6aa2e082d2a388b194afe1ba0a05d14", + "reference": "da1d1111a6aa2e082d2a388b194afe1ba0a05d14", "shasum": "" }, "require": { @@ -10409,7 +10409,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-06-16T12:31:41+00:00" + "time": "2026-06-16T15:34:04+00:00" }, { "name": "laravel/sail", diff --git a/package-lock.json b/package-lock.json index 31569c7a..0bc77829 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,9 +31,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -305,6 +305,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -322,6 +325,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -339,6 +345,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -356,6 +365,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -373,6 +385,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -390,6 +405,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -496,6 +514,19 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -604,14 +635,15 @@ } }, "node_modules/axios": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", - "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", + "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", "dev": true, "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, @@ -622,9 +654,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", - "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", + "version": "2.10.37", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", + "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", "dev": true, "license": "Apache-2.0", "bin": { @@ -653,9 +685,9 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -742,9 +774,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001788", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", - "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "dev": true, "funding": [ { @@ -1065,6 +1097,24 @@ "node": ">=4" } }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1209,9 +1259,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.336", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.336.tgz", - "integrity": "sha512-AbH9q9J455r/nLmdNZes0G0ZKcRX73FicwowalLs6ijwOmCJSRRrLX63lcAlzy9ux3dWK1w1+1nsBJEWN11hcQ==", + "version": "1.5.374", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.374.tgz", + "integrity": "sha512-HCF5i7izveksHSGqa7mhDh6tr3Uz9Dar2RAjwuh69bw3QGPVObjQIgLwQWeO/Rxp9/r0KdboKy9RbpQDl97fjg==", "dev": true, "license": "ISC" }, @@ -1254,9 +1304,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "dev": true, "license": "MIT", "dependencies": { @@ -1629,6 +1679,20 @@ "entities": "^4.4.0" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", @@ -1648,13 +1712,13 @@ } }, "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", "dev": true, "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "hasown": "^2.0.3" }, "engines": { "node": ">= 0.4" @@ -1755,13 +1819,10 @@ } }, "node_modules/js-cookie": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.7.tgz", - "integrity": "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==", - "license": "MIT", - "engines": { - "node": ">=20" - } + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "license": "MIT" }, "node_modules/juice": { "version": "10.0.1", @@ -1961,6 +2022,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1982,6 +2046,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2003,6 +2070,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2024,6 +2094,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2641,6 +2714,13 @@ "mjml-section": "4.18.0" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -2702,11 +2782,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.37", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", - "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/nopt": { "version": "7.2.1", @@ -3030,9 +3113,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3229,9 +3312,9 @@ } }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "license": "ISC", "bin": { "semver": "bin/semver.js" From 6265442f10ac383cd2dc814a8dc10be5b64a2cff Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Tue, 16 Jun 2026 22:47:39 +0200 Subject: [PATCH 04/13] chore: tweak sorting, add guard to db seeders --- .../Resources/PolydockAppInstanceResource.php | 1 + .../Pages/ListPolydockAppInstances.php | 46 +++ .../PolydockStoreWebhookCallResource.php | 1 + .../Admin/Resources/UserGroupResource.php | 1 + .../AppInstancesRelationManager.php | 1 + .../UserRemoteRegistrationResource.php | 1 + app/Filament/Admin/Resources/UserResource.php | 1 + database/seeders/DatabaseSeeder.php | 370 +++++++++--------- tests/Feature/Console/DatabaseSeederTest.php | 48 +++ 9 files changed, 286 insertions(+), 184 deletions(-) create mode 100644 tests/Feature/Console/DatabaseSeederTest.php diff --git a/app/Filament/Admin/Resources/PolydockAppInstanceResource.php b/app/Filament/Admin/Resources/PolydockAppInstanceResource.php index 57f2c57d..138d5ecb 100644 --- a/app/Filament/Admin/Resources/PolydockAppInstanceResource.php +++ b/app/Filament/Admin/Resources/PolydockAppInstanceResource.php @@ -61,6 +61,7 @@ public static function table(Table $table): Table { return $table ->searchable() + ->defaultSort('created_at', 'desc') ->columns([ TextColumn::make('name') ->description(fn ($record) => $record->storeApp->store->name.' - '.$record->storeApp->name) diff --git a/app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/ListPolydockAppInstances.php b/app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/ListPolydockAppInstances.php index c1128e49..71bf2d6c 100644 --- a/app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/ListPolydockAppInstances.php +++ b/app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/ListPolydockAppInstances.php @@ -5,8 +5,12 @@ namespace App\Filament\Admin\Resources\PolydockAppInstanceResource\Pages; use App\Filament\Admin\Resources\PolydockAppInstanceResource; +use App\Models\PolydockAppInstance; use Filament\Actions; +use Filament\Resources\Components\Tab; use Filament\Resources\Pages\ListRecords; +use FreedomtechHosting\PolydockApp\Enums\PolydockAppInstanceStatus; +use Illuminate\Database\Eloquent\Builder; class ListPolydockAppInstances extends ListRecords { @@ -19,4 +23,46 @@ protected function getHeaderActions(): array Actions\CreateAction::make(), ]; } + + public function getTabs(): array + { + return [ + 'active' => Tab::make('Active') + ->modifyQueryUsing(fn (Builder $query) => $query->where('status', '!=', PolydockAppInstanceStatus::REMOVED)) + ->badge(static::$resource::getEloquentQuery()->where('status', '!=', PolydockAppInstanceStatus::REMOVED)->count()), + + 'in_progress' => Tab::make('In Progress') + ->modifyQueryUsing(fn (Builder $query) => $query->whereIn('status', [ + PolydockAppInstanceStatus::NEW, + ...PolydockAppInstance::$stageCreateStatuses, + ...PolydockAppInstance::$stageDeployStatuses, + ...PolydockAppInstance::$stageClaimStatuses, + ...PolydockAppInstance::$stageUpgradeStatuses, + ...array_filter(PolydockAppInstance::$stageRemoveStatuses, fn ($status) => $status !== PolydockAppInstanceStatus::REMOVED), + ])) + ->badge(static::$resource::getEloquentQuery()->whereIn('status', [ + PolydockAppInstanceStatus::NEW, + ...PolydockAppInstance::$stageCreateStatuses, + ...PolydockAppInstance::$stageDeployStatuses, + ...PolydockAppInstance::$stageClaimStatuses, + ...PolydockAppInstance::$stageUpgradeStatuses, + ...array_filter(PolydockAppInstance::$stageRemoveStatuses, fn ($status) => $status !== PolydockAppInstanceStatus::REMOVED), + ])->count()), + + 'healthy_claimed' => Tab::make('Healthy (Claimed)') + ->modifyQueryUsing(fn (Builder $query) => $query->where('status', PolydockAppInstanceStatus::RUNNING_HEALTHY_CLAIMED)) + ->badge(static::$resource::getEloquentQuery()->where('status', PolydockAppInstanceStatus::RUNNING_HEALTHY_CLAIMED)->count()), + + 'healthy_unclaimed' => Tab::make('Healthy (Unclaimed)') + ->modifyQueryUsing(fn (Builder $query) => $query->where('status', PolydockAppInstanceStatus::RUNNING_HEALTHY_UNCLAIMED)) + ->badge(static::$resource::getEloquentQuery()->where('status', PolydockAppInstanceStatus::RUNNING_HEALTHY_UNCLAIMED)->count()), + + 'removed' => Tab::make('Removed') + ->modifyQueryUsing(fn (Builder $query) => $query->where('status', PolydockAppInstanceStatus::REMOVED)) + ->badge(static::$resource::getEloquentQuery()->where('status', PolydockAppInstanceStatus::REMOVED)->count()), + + 'all' => Tab::make('All') + ->badge(static::$resource::getEloquentQuery()->count()), + ]; + } } diff --git a/app/Filament/Admin/Resources/PolydockStoreWebhookCallResource.php b/app/Filament/Admin/Resources/PolydockStoreWebhookCallResource.php index e8ecc5cd..dd6a7ad7 100644 --- a/app/Filament/Admin/Resources/PolydockStoreWebhookCallResource.php +++ b/app/Filament/Admin/Resources/PolydockStoreWebhookCallResource.php @@ -26,6 +26,7 @@ class PolydockStoreWebhookCallResource extends Resource public static function table(Table $table): Table { return $table + ->defaultSort('created_at', 'desc') ->columns([ Tables\Columns\TextColumn::make('webhook.store.name') ->description(fn (PolydockStoreWebhookCall $record) => $record->webhook->url) diff --git a/app/Filament/Admin/Resources/UserGroupResource.php b/app/Filament/Admin/Resources/UserGroupResource.php index ade402cc..ea78a00b 100644 --- a/app/Filament/Admin/Resources/UserGroupResource.php +++ b/app/Filament/Admin/Resources/UserGroupResource.php @@ -65,6 +65,7 @@ public static function table(Table $table): Table { return $table ->searchable() + ->defaultSort('created_at', 'desc') ->columns([ TextColumn::make('name') ->searchable() diff --git a/app/Filament/Admin/Resources/UserGroupResource/RelationManagers/AppInstancesRelationManager.php b/app/Filament/Admin/Resources/UserGroupResource/RelationManagers/AppInstancesRelationManager.php index 5e107d38..ba6a318a 100644 --- a/app/Filament/Admin/Resources/UserGroupResource/RelationManagers/AppInstancesRelationManager.php +++ b/app/Filament/Admin/Resources/UserGroupResource/RelationManagers/AppInstancesRelationManager.php @@ -29,6 +29,7 @@ public function table(Table $table): Table { return $table ->recordTitleAttribute('name') + ->defaultSort('created_at', 'desc') ->columns([ Tables\Columns\TextColumn::make('name') ->searchable() diff --git a/app/Filament/Admin/Resources/UserRemoteRegistrationResource.php b/app/Filament/Admin/Resources/UserRemoteRegistrationResource.php index ba7e5460..05cdc9c8 100644 --- a/app/Filament/Admin/Resources/UserRemoteRegistrationResource.php +++ b/app/Filament/Admin/Resources/UserRemoteRegistrationResource.php @@ -39,6 +39,7 @@ public static function table(Table $table): Table { return $table ->searchable() + ->defaultSort('created_at', 'desc') ->columns([ TextColumn::make('type') ->badge() diff --git a/app/Filament/Admin/Resources/UserResource.php b/app/Filament/Admin/Resources/UserResource.php index b37b5caa..d8742753 100644 --- a/app/Filament/Admin/Resources/UserResource.php +++ b/app/Filament/Admin/Resources/UserResource.php @@ -88,6 +88,7 @@ public static function table(Table $table): Table { return $table ->searchable() + ->defaultSort('created_at', 'desc') ->columns([ TextColumn::make('first_name') ->searchable() diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 98b91a7c..4370952e 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -28,205 +28,207 @@ public function run(): void SuperAdminRoleSeeder::class, ]); - // Create Fred and his team - $fred = User::create([ - 'first_name' => 'Fred', - 'last_name' => 'Blogs', - 'email' => 'fred@example.com', - 'password' => Hash::make('password'), - ]); - - $fred->assignRole('super_admin'); - - $fredsTeam = UserGroup::create([ - 'name' => 'Fracme Inc.', - ]); + if (! app()->environment('production', 'prod')) { + // Create Fred and his team + $fred = User::create([ + 'first_name' => 'Fred', + 'last_name' => 'Blogs', + 'email' => 'fred@example.com', + 'password' => Hash::make('password'), + ]); - // Make Fred the owner - $fred->groups()->attach($fredsTeam, [ - 'role' => UserGroupRoleEnum::OWNER->value, - ]); + $fred->assignRole('super_admin'); - // Create team members with predefined details - $teamMembers = [ - [ - 'first_name' => 'Alice', - 'last_name' => 'Smith', - 'email' => 'alice@example.com', - ], - [ - 'first_name' => 'Bob', - 'last_name' => 'Jones', - 'email' => 'bob@example.com', - ], - [ - 'first_name' => 'Carol', - 'last_name' => 'Wilson', - 'email' => 'carol@example.com', - ], - ]; - - // Create and attach team members - foreach ($teamMembers as $member) { - $user = User::create([ - 'first_name' => $member['first_name'], - 'last_name' => $member['last_name'], - 'email' => $member['email'], - 'password' => Hash::make('password'), + $fredsTeam = UserGroup::create([ + 'name' => 'Fracme Inc.', ]); - $user->groups()->attach($fredsTeam, [ - 'role' => UserGroupRoleEnum::MEMBER->value, + // Make Fred the owner + $fred->groups()->attach($fredsTeam, [ + 'role' => UserGroupRoleEnum::OWNER->value, ]); - } - $deployKey = file_get_contents(config('polydock.lagoon_deploy_private_key_file')); - - // Create the stores - $usaStore = PolydockStore::create([ - 'name' => 'USA Store', - 'status' => PolydockStoreStatusEnum::PUBLIC, - 'listed_in_marketplace' => true, - 'lagoon_deploy_region_id_ext' => '1', - 'lagoon_deploy_project_prefix' => 'ft-us', - 'lagoon_deploy_organization_id_ext' => '271', - 'amazee_ai_backend_region_id_ext' => 34, - 'lagoon_deploy_group_name' => 'polydock-demo-apps', - ]); - $usaStore->setPolydockVariableValue('lagoon_deploy_private_key', $deployKey, true); - - $switzerlandStore = PolydockStore::create([ - 'name' => 'Switzerland Store', - 'status' => PolydockStoreStatusEnum::PUBLIC, - 'listed_in_marketplace' => true, - 'lagoon_deploy_region_id_ext' => '1', - 'lagoon_deploy_project_prefix' => 'ft-ch', - 'lagoon_deploy_organization_id_ext' => '271', - 'amazee_ai_backend_region_id_ext' => 34, - 'lagoon_deploy_group_name' => 'polydock-demo-apps', - ]); - $switzerlandStore->setPolydockVariableValue('lagoon_deploy_private_key', $deployKey, true); + // Create team members with predefined details + $teamMembers = [ + [ + 'first_name' => 'Alice', + 'last_name' => 'Smith', + 'email' => 'alice@example.com', + ], + [ + 'first_name' => 'Bob', + 'last_name' => 'Jones', + 'email' => 'bob@example.com', + ], + [ + 'first_name' => 'Carol', + 'last_name' => 'Wilson', + 'email' => 'carol@example.com', + ], + ]; + + // Create and attach team members + foreach ($teamMembers as $member) { + $user = User::create([ + 'first_name' => $member['first_name'], + 'last_name' => $member['last_name'], + 'email' => $member['email'], + 'password' => Hash::make('password'), + ]); + + $user->groups()->attach($fredsTeam, [ + 'role' => UserGroupRoleEnum::MEMBER->value, + ]); + } + + $deployKey = file_get_contents(config('polydock.lagoon_deploy_private_key_file')); + + // Create the stores + $usaStore = PolydockStore::create([ + 'name' => 'USA Store', + 'status' => PolydockStoreStatusEnum::PUBLIC, + 'listed_in_marketplace' => true, + 'lagoon_deploy_region_id_ext' => '1', + 'lagoon_deploy_project_prefix' => 'ft-us', + 'lagoon_deploy_organization_id_ext' => '271', + 'amazee_ai_backend_region_id_ext' => 34, + 'lagoon_deploy_group_name' => 'polydock-demo-apps', + ]); + $usaStore->setPolydockVariableValue('lagoon_deploy_private_key', $deployKey, true); + + $switzerlandStore = PolydockStore::create([ + 'name' => 'Switzerland Store', + 'status' => PolydockStoreStatusEnum::PUBLIC, + 'listed_in_marketplace' => true, + 'lagoon_deploy_region_id_ext' => '1', + 'lagoon_deploy_project_prefix' => 'ft-ch', + 'lagoon_deploy_organization_id_ext' => '271', + 'amazee_ai_backend_region_id_ext' => 34, + 'lagoon_deploy_group_name' => 'polydock-demo-apps', + ]); + $switzerlandStore->setPolydockVariableValue('lagoon_deploy_private_key', $deployKey, true); - // Add webhook to both stores - $webhookUrl = 'https://webhook.site/bbe9c2ef-bb18-4c13-8d40-14fb428c7b64'; + // Add webhook to both stores + $webhookUrl = 'https://webhook.site/bbe9c2ef-bb18-4c13-8d40-14fb428c7b64'; - PolydockStoreWebhook::create([ - 'polydock_store_id' => $usaStore->id, - 'url' => $webhookUrl, - 'active' => true, - ]); + PolydockStoreWebhook::create([ + 'polydock_store_id' => $usaStore->id, + 'url' => $webhookUrl, + 'active' => true, + ]); - PolydockStoreWebhook::create([ - 'polydock_store_id' => $switzerlandStore->id, - 'url' => $webhookUrl, - 'active' => true, - ]); + PolydockStoreWebhook::create([ + 'polydock_store_id' => $switzerlandStore->id, + 'url' => $webhookUrl, + 'active' => true, + ]); - PolydockStoreApp::create([ - 'polydock_store_id' => $usaStore->id, - 'name' => 'USA Simple amazee.io AI Node.js', - 'polydock_app_class' => PolydockAiApp::class, - 'description' => 'A simple amazee.io AI Node.js app deployed to the USA', - 'author' => 'Bryan Gruneberg', - 'website' => 'https://freedomtech.hosting/', - 'support_email' => 'hello@freedomtech.hosting', - 'lagoon_deploy_git' => 'git@github.com:Freedomtech-Hosting/polydock-demo-node-simple.git', - 'lagoon_deploy_branch' => 'main', - 'status' => PolydockStoreAppStatusEnum::AVAILABLE, - 'available_for_trials' => true, - 'target_unallocated_app_instances' => 0, - ]); + PolydockStoreApp::create([ + 'polydock_store_id' => $usaStore->id, + 'name' => 'USA Simple amazee.io AI Node.js', + 'polydock_app_class' => PolydockAiApp::class, + 'description' => 'A simple amazee.io AI Node.js app deployed to the USA', + 'author' => 'Bryan Gruneberg', + 'website' => 'https://freedomtech.hosting/', + 'support_email' => 'hello@freedomtech.hosting', + 'lagoon_deploy_git' => 'git@github.com:Freedomtech-Hosting/polydock-demo-node-simple.git', + 'lagoon_deploy_branch' => 'main', + 'status' => PolydockStoreAppStatusEnum::AVAILABLE, + 'available_for_trials' => true, + 'target_unallocated_app_instances' => 0, + ]); - PolydockStoreApp::create([ - 'polydock_store_id' => $switzerlandStore->id, - 'name' => 'Switzerland Simple amazee.io Node.js', - 'polydock_app_class' => PolydockApp::class, - 'description' => 'A simple amazee.io Node.js app deployed to Switzerland', - 'author' => 'Bryan Gruneberg', - 'website' => 'https://freedomtech.hosting/', - 'support_email' => 'hello@freedomtech.hosting', - 'lagoon_deploy_git' => 'git@github.com:Freedomtech-Hosting/polydock-demo-node-simple.git', - 'lagoon_deploy_branch' => 'main', - 'status' => PolydockStoreAppStatusEnum::AVAILABLE, - 'available_for_trials' => true, - 'target_unallocated_app_instances' => 0, - ]); + PolydockStoreApp::create([ + 'polydock_store_id' => $switzerlandStore->id, + 'name' => 'Switzerland Simple amazee.io Node.js', + 'polydock_app_class' => PolydockApp::class, + 'description' => 'A simple amazee.io Node.js app deployed to Switzerland', + 'author' => 'Bryan Gruneberg', + 'website' => 'https://freedomtech.hosting/', + 'support_email' => 'hello@freedomtech.hosting', + 'lagoon_deploy_git' => 'git@github.com:Freedomtech-Hosting/polydock-demo-node-simple.git', + 'lagoon_deploy_branch' => 'main', + 'status' => PolydockStoreAppStatusEnum::AVAILABLE, + 'available_for_trials' => true, + 'target_unallocated_app_instances' => 0, + ]); - PolydockStoreApp::create([ - 'polydock_store_id' => $usaStore->id, - 'name' => 'USA amazee.io AI - Categorize Pages', - 'polydock_app_class' => PolydockAiApp::class, - 'description' => 'A demo of amazee.io AI - Categorize Pages Functionality', - 'author' => 'Bryan Gruneberg', - 'website' => 'https://try.amazee.ai/', - 'support_email' => 'ai.support@amazee.io', - 'lagoon_deploy_git' => 'git@github.com:amazeeio-demos/polydock-ai-trial-drupal-cms-caegorize-page.git', - 'lagoon_deploy_branch' => 'main', - 'status' => PolydockStoreAppStatusEnum::AVAILABLE, - 'available_for_trials' => true, - 'target_unallocated_app_instances' => 1, - 'lagoon_post_deploy_script' => '/app/.lagoon/scripts/polydock_post_deploy.sh', - 'lagoon_claim_script' => '/app/.lagoon/scripts/polydock_claim.sh', - ]); + PolydockStoreApp::create([ + 'polydock_store_id' => $usaStore->id, + 'name' => 'USA amazee.io AI - Categorize Pages', + 'polydock_app_class' => PolydockAiApp::class, + 'description' => 'A demo of amazee.io AI - Categorize Pages Functionality', + 'author' => 'Bryan Gruneberg', + 'website' => 'https://try.amazee.ai/', + 'support_email' => 'ai.support@amazee.io', + 'lagoon_deploy_git' => 'git@github.com:amazeeio-demos/polydock-ai-trial-drupal-cms-caegorize-page.git', + 'lagoon_deploy_branch' => 'main', + 'status' => PolydockStoreAppStatusEnum::AVAILABLE, + 'available_for_trials' => true, + 'target_unallocated_app_instances' => 1, + 'lagoon_post_deploy_script' => '/app/.lagoon/scripts/polydock_post_deploy.sh', + 'lagoon_claim_script' => '/app/.lagoon/scripts/polydock_claim.sh', + ]); - PolydockStoreApp::create([ - 'polydock_store_id' => $switzerlandStore->id, - 'name' => 'Switzerland amazee.io AI - Categorize Pages', - 'polydock_app_class' => PolydockAiApp::class, - 'description' => 'A demo of amazee.io AI - Categorize Pages Functionality', - 'author' => 'Bryan Gruneberg', - 'website' => 'https://try.amazee.ai/', - 'support_email' => 'ai.support@amazee.io', - 'lagoon_deploy_git' => 'git@github.com:amazeeio-demos/polydock-ai-trial-drupal-cms-caegorize-page.git', - 'lagoon_deploy_branch' => 'main', - 'status' => PolydockStoreAppStatusEnum::AVAILABLE, - 'available_for_trials' => true, - 'target_unallocated_app_instances' => 0, - 'lagoon_post_deploy_script' => '/app/.lagoon/scripts/polydock_post_deploy.sh', - 'lagoon_claim_script' => '/app/.lagoon/scripts/polydock_claim.sh', - ]); + PolydockStoreApp::create([ + 'polydock_store_id' => $switzerlandStore->id, + 'name' => 'Switzerland amazee.io AI - Categorize Pages', + 'polydock_app_class' => PolydockAiApp::class, + 'description' => 'A demo of amazee.io AI - Categorize Pages Functionality', + 'author' => 'Bryan Gruneberg', + 'website' => 'https://try.amazee.ai/', + 'support_email' => 'ai.support@amazee.io', + 'lagoon_deploy_git' => 'git@github.com:amazeeio-demos/polydock-ai-trial-drupal-cms-caegorize-page.git', + 'lagoon_deploy_branch' => 'main', + 'status' => PolydockStoreAppStatusEnum::AVAILABLE, + 'available_for_trials' => true, + 'target_unallocated_app_instances' => 0, + 'lagoon_post_deploy_script' => '/app/.lagoon/scripts/polydock_post_deploy.sh', + 'lagoon_claim_script' => '/app/.lagoon/scripts/polydock_claim.sh', + ]); - PolydockStoreApp::create([ - 'polydock_store_id' => $usaStore->id, - 'name' => 'USA amazee.io AI - CK Editor', - 'polydock_app_class' => PolydockAiApp::class, - 'description' => 'A demo of amazee.io AI - CK Editor Functionality', - 'author' => 'Bryan Gruneberg', - 'website' => 'https://try.amazee.ai/', - 'support_email' => 'ai.support@amazee.io', - 'lagoon_deploy_git' => 'git@github.com:amazeeio-demos/polydock-ai-trial-drupal-cms-ck-editor.git', - 'lagoon_deploy_branch' => 'main', - 'status' => PolydockStoreAppStatusEnum::AVAILABLE, - 'available_for_trials' => true, - 'target_unallocated_app_instances' => 0, - 'lagoon_post_deploy_script' => '/app/.lagoon/scripts/polydock_post_deploy.sh', - 'lagoon_claim_script' => '/app/.lagoon/scripts/polydock_claim.sh', - ]); + PolydockStoreApp::create([ + 'polydock_store_id' => $usaStore->id, + 'name' => 'USA amazee.io AI - CK Editor', + 'polydock_app_class' => PolydockAiApp::class, + 'description' => 'A demo of amazee.io AI - CK Editor Functionality', + 'author' => 'Bryan Gruneberg', + 'website' => 'https://try.amazee.ai/', + 'support_email' => 'ai.support@amazee.io', + 'lagoon_deploy_git' => 'git@github.com:amazeeio-demos/polydock-ai-trial-drupal-cms-ck-editor.git', + 'lagoon_deploy_branch' => 'main', + 'status' => PolydockStoreAppStatusEnum::AVAILABLE, + 'available_for_trials' => true, + 'target_unallocated_app_instances' => 0, + 'lagoon_post_deploy_script' => '/app/.lagoon/scripts/polydock_post_deploy.sh', + 'lagoon_claim_script' => '/app/.lagoon/scripts/polydock_claim.sh', + ]); - PolydockStoreApp::create([ - 'polydock_store_id' => $switzerlandStore->id, - 'name' => 'Switzerland amazee.io AI - CK Editor', - 'polydock_app_class' => PolydockAiApp::class, - 'description' => 'A demo of amazee.io AI - CK Editor Functionality', - 'author' => 'Bryan Gruneberg', - 'website' => 'https://try.amazee.ai/', - 'support_email' => 'ai.support@amazee.io', - 'lagoon_deploy_git' => 'git@github.com:amazeeio-demos/polydock-ai-trial-drupal-cms-ck-editor.git', - 'lagoon_deploy_branch' => 'main', - 'status' => PolydockStoreAppStatusEnum::AVAILABLE, - 'available_for_trials' => true, - 'target_unallocated_app_instances' => 0, - 'lagoon_post_deploy_script' => '/app/.lagoon/scripts/polydock_post_deploy.sh', - 'lagoon_claim_script' => '/app/.lagoon/scripts/polydock_claim.sh', - ]); + PolydockStoreApp::create([ + 'polydock_store_id' => $switzerlandStore->id, + 'name' => 'Switzerland amazee.io AI - CK Editor', + 'polydock_app_class' => PolydockAiApp::class, + 'description' => 'A demo of amazee.io AI - CK Editor Functionality', + 'author' => 'Bryan Gruneberg', + 'website' => 'https://try.amazee.ai/', + 'support_email' => 'ai.support@amazee.io', + 'lagoon_deploy_git' => 'git@github.com:amazeeio-demos/polydock-ai-trial-drupal-cms-ck-editor.git', + 'lagoon_deploy_branch' => 'main', + 'status' => PolydockStoreAppStatusEnum::AVAILABLE, + 'available_for_trials' => true, + 'target_unallocated_app_instances' => 0, + 'lagoon_post_deploy_script' => '/app/.lagoon/scripts/polydock_post_deploy.sh', + 'lagoon_claim_script' => '/app/.lagoon/scripts/polydock_claim.sh', + ]); - // Add some example apps to each store - PolydockStoreApp::factory() - ->count(8) - ->sequence( - ['polydock_store_id' => $usaStore->id], - ['polydock_store_id' => $switzerlandStore->id], - ) - ->create(); + // Add some example apps to each store + PolydockStoreApp::factory() + ->count(8) + ->sequence( + ['polydock_store_id' => $usaStore->id], + ['polydock_store_id' => $switzerlandStore->id], + ) + ->create(); + } } } diff --git a/tests/Feature/Console/DatabaseSeederTest.php b/tests/Feature/Console/DatabaseSeederTest.php new file mode 100644 index 00000000..f7e5a648 --- /dev/null +++ b/tests/Feature/Console/DatabaseSeederTest.php @@ -0,0 +1,48 @@ +seed(); + + // Fred and team members (Alice, Bob, Carol) should be created + $this->assertDatabaseHas('users', ['email' => 'fred@example.com']); + $this->assertDatabaseHas('users', ['email' => 'alice@example.com']); + $this->assertDatabaseHas('users', ['email' => 'bob@example.com']); + $this->assertDatabaseHas('users', ['email' => 'carol@example.com']); + + // Stores should be created + $this->assertGreaterThan(0, PolydockStore::count()); + } + + /** + * Test that mock data is NOT seeded in production environment. + */ + public function test_it_does_not_seed_mock_data_in_production_environment(): void + { + // Mock the environment to production + $this->app->detectEnvironment(fn () => 'production'); + $this->assertEquals('production', app()->environment()); + + // Run seeder in production environment with --force to bypass confirmation prompt + $this->artisan('db:seed', ['--force' => true]); + + // Fred, team members, and stores should NOT be created + $this->assertEquals(0, User::count()); + $this->assertEquals(0, PolydockStore::count()); + } +} From 6876a153ab7591e15c955a286c4e6b960c7f11e4 Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Tue, 16 Jun 2026 23:20:39 +0200 Subject: [PATCH 05/13] chore: prevent spam users --- app/Console/Commands/BanEmailsCommand.php | 326 +++++++++++++++ .../Commands/UpdateDisposableDomains.php | 42 ++ .../Api/AuthenticatedApiController.php | 3 +- .../Controllers/Api/RegisterController.php | 19 + app/Models/PolydockBannedPattern.php | 36 ++ app/Rules/BannedEmail.php | 36 ++ app/Services/EmailBlockerResult.php | 51 +++ app/Services/EmailBlockerService.php | 163 ++++++++ ..._create_polydock_banned_patterns_table.php | 29 ++ database/seeders/DatabaseSeeder.php | 5 +- .../Console/Commands/BanEmailsCommandTest.php | 374 ++++++++++++++++++ 11 files changed, 1082 insertions(+), 2 deletions(-) create mode 100644 app/Console/Commands/BanEmailsCommand.php create mode 100644 app/Console/Commands/UpdateDisposableDomains.php create mode 100644 app/Models/PolydockBannedPattern.php create mode 100644 app/Rules/BannedEmail.php create mode 100644 app/Services/EmailBlockerResult.php create mode 100644 app/Services/EmailBlockerService.php create mode 100644 database/migrations/2026_06_16_231500_create_polydock_banned_patterns_table.php create mode 100644 tests/Feature/Console/Commands/BanEmailsCommandTest.php diff --git a/app/Console/Commands/BanEmailsCommand.php b/app/Console/Commands/BanEmailsCommand.php new file mode 100644 index 00000000..564c824c --- /dev/null +++ b/app/Console/Commands/BanEmailsCommand.php @@ -0,0 +1,326 @@ +argument('patterns'); + $reason = $this->option('reason') ?: 'Banned via administrative cleanup command'; + $isDryRun = $this->option('dry-run'); + $force = $this->option('force'); + + // 1. Normalize the patterns to secure wildcard domain bans + $patterns = $this->normalizePatterns($inputPatterns); + + $this->info('Normalized patterns to ban:'); + foreach ($patterns as $pattern) { + $this->line(" - {$pattern}"); + } + $this->newLine(); + + // 2. Identify Matching Users + $users = $this->findMatchingUsers($patterns); + $bannedUserIds = $users->pluck('id')->toArray(); + + // 3. Identify User Groups associated with these users + $groupsToCheck = UserGroup::whereHas('users', function ($query) use ($bannedUserIds) { + $query->whereIn('user_id', $bannedUserIds); + })->get(); + + // 4. Identify Matching Registrations + $registrations = $this->findMatchingRegistrations($patterns, $bannedUserIds); + + // 5. Identify Matching Polydock App Instances + $instances = $this->findMatchingAppInstances($patterns, $groupsToCheck->pluck('id')->toArray()); + + // 6. Output dry run information or prompt for confirmation + if ($users->isEmpty() && $registrations->isEmpty() && $instances->isEmpty()) { + $this->warn('No existing users, registrations, or app instances match these patterns.'); + } else { + $this->comment('Summary of affected records:'); + $this->line(" - Users to delete: {$users->count()}"); + $this->line(" - Registrations to fail: {$registrations->count()}"); + $this->line(" - App instances to purge: {$instances->count()}"); + $this->newLine(); + + if ($users->isNotEmpty()) { + $this->info('Matching Users:'); + foreach ($users as $user) { + $this->line(" ID: {$user->id} | Email: {$user->email} | Name: {$user->name}"); + } + $this->newLine(); + } + + if ($registrations->isNotEmpty()) { + $this->info('Matching Registrations:'); + foreach ($registrations as $reg) { + $this->line(" ID: {$reg->id} | Email: {$reg->email} | Status: {$reg->status->value}"); + } + $this->newLine(); + } + + if ($instances->isNotEmpty()) { + $this->info('Matching App Instances:'); + foreach ($instances as $instance) { + $email = $instance->getUserEmail() ?: 'N/A'; + $this->line(" ID: {$instance->id} | Name: {$instance->name} | Email: {$email} | Status: {$instance->status->getLabel()}"); + } + $this->newLine(); + } + } + + if ($isDryRun) { + $this->info('DRY RUN: No modifications were made.'); + + return 0; + } + + if (! $force) { + if (! $this->confirm('Are you sure you want to add these bans and proceed with the cleanup?', false)) { + $this->info('Operation cancelled.'); + + return 0; + } + } + + $deletedGroups = []; + + // 7. Perform DB modifications inside a transaction for atomic safety + DB::transaction(function () use ($patterns, $reason, $users, $groupsToCheck, $registrations, $instances, &$deletedGroups) { + // Save patterns in polydock_banned_patterns table + foreach ($patterns as $pattern) { + PolydockBannedPattern::firstOrCreate( + ['pattern' => $pattern], + ['reason' => $reason] + ); + } + + // Mark matched registrations as failed + foreach ($registrations as $registration) { + if ($registration->status !== UserRemoteRegistrationStatusEnum::FAILED) { + $registration->status = UserRemoteRegistrationStatusEnum::FAILED; + $registration->save(); + } + } + + // Initiate graceful force-purge for matched app instances + foreach ($instances as $instance) { + // Skip if already fully removed or in removal/purge stages + if (in_array($instance->status, PolydockAppInstance::$stageRemoveStatuses, true) || + in_array($instance->status, PolydockAppInstance::$stagePurgeStatuses, true)) { + continue; + } + + $instance->force_purge_requested_at = now(); + $instance->setStatus( + PolydockAppInstanceStatus::PENDING_PRE_REMOVE, + "Terminated and queued for immediate purge by ban system: {$reason}" + ); + $instance->save(); + } + + // Delete users and clean up empty groups + if ($users->isNotEmpty()) { + foreach ($users as $user) { + $user->groups()->detach(); + $user->delete(); + } + + // Check groups that became empty or have no other active members + foreach ($groupsToCheck as $group) { + $remainingUsersCount = $group->users()->count(); + if ($remainingUsersCount === 0) { + // Delete any app instances in this group that might not have matched the email search + $groupInstances = $group->appInstances() + ->whereNotIn('status', PolydockAppInstance::$stageRemoveStatuses) + ->whereNotIn('status', PolydockAppInstance::$stagePurgeStatuses) + ->get(); + + foreach ($groupInstances as $gInstance) { + $gInstance->force_purge_requested_at = now(); + $gInstance->setStatus( + PolydockAppInstanceStatus::PENDING_PRE_REMOVE, + "Group empty. Terminated and queued for immediate purge by ban system: {$reason}" + ); + $gInstance->save(); + } + + $group->delete(); + $deletedGroups[] = [ + 'name' => $group->name, + 'id' => $group->id, + ]; + } + } + } + }); + + foreach ($deletedGroups as $deletedGroup) { + $this->line("Deleted empty UserGroup: {$deletedGroup['name']} (ID: {$deletedGroup['id']})"); + } + + $this->info('Ban and cleanup operation executed successfully.'); + + return 0; + } + + /** + * Normalize individual email and domain inputs to precise SQL-safe patterns. + */ + protected function normalizePatterns(array $inputs): array + { + $normalized = []; + foreach ($inputs as $input) { + $input = trim(strtolower($input)); + if (empty($input)) { + continue; + } + + // If it is already a wildcard email pattern (like *@domain.com or *@*.domain.com) + if (str_starts_with($input, '*@')) { + $normalized[] = $input; + $domain = substr($input, 2); + if (! str_contains($domain, '*.')) { + $normalized[] = "*@*.{$domain}"; + } + + continue; + } + + // If it starts with @ (like @spam.com) + if (str_starts_with($input, '@')) { + $domain = ltrim($input, '@'); + $normalized[] = "*@{$domain}"; + $normalized[] = "*@*.{$domain}"; + + continue; + } + + // If it contains @ but it's an email (like spammer@gmail.com) + if (str_contains($input, '@')) { + $normalized[] = $input; + + continue; + } + + // If it doesn't contain @, treat as a domain name (like spam.com) + $normalized[] = "*@{$input}"; + $normalized[] = "*@*.{$input}"; + } + + return array_values(array_unique($normalized)); + } + + /** + * Escape PHP patterns for SQL LIKE query with ESCAPE '=' clause. + */ + protected function escapeLikePattern(string $pattern): string + { + $escaped = str_replace('=', '==', $pattern); + $escaped = str_replace('%', '=%', $escaped); + $escaped = str_replace('_', '=_', $escaped); + + return str_replace('*', '%', $escaped); + } + + /** + * Find existing users matching any of the normalized patterns. + */ + protected function findMatchingUsers(array $patterns): Collection + { + if (empty($patterns)) { + return new Collection; + } + + return User::where(function ($query) use ($patterns) { + foreach ($patterns as $pattern) { + $escapedPattern = $this->escapeLikePattern($pattern); + $query->orWhereRaw("email LIKE ? ESCAPE '='", [$escapedPattern]); + } + })->get(); + } + + /** + * Find registrations matching patterns or linked to matched user IDs. + */ + protected function findMatchingRegistrations(array $patterns, array $userIds): Collection + { + if (empty($patterns) && empty($userIds)) { + return new Collection; + } + + return UserRemoteRegistration::where(function ($query) use ($patterns, $userIds) { + if (! empty($userIds)) { + $query->whereIn('user_id', $userIds); + } + foreach ($patterns as $pattern) { + $escapedPattern = $this->escapeLikePattern($pattern); + $query->orWhereRaw("email LIKE ? ESCAPE '='", [$escapedPattern]); + } + })->get(); + } + + /** + * Find app instances matching patterns or associated user group IDs. + */ + protected function findMatchingAppInstances(array $patterns, array $groupIds): Collection + { + if (empty($patterns) && empty($groupIds)) { + return new Collection; + } + + $connectionType = DB::connection()->getDriverName(); + + return PolydockAppInstance::where(function ($query) use ($patterns, $groupIds, $connectionType) { + if (! empty($groupIds)) { + $query->whereIn('user_group_id', $groupIds); + } + + foreach ($patterns as $pattern) { + $escapedPattern = $this->escapeLikePattern($pattern); + + if ($connectionType === 'sqlite') { + // SQLite handles extraction nicely, and json_unquote doesn't exist. + // SQLite extraction also automatically removes quotes from scalar strings. + $query->orWhereRaw("json_extract(data, '$.\"user-email\"') LIKE ? ESCAPE '='", [$escapedPattern]); + } else { + $query->orWhereRaw("JSON_UNQUOTE(JSON_EXTRACT(data, '$.\"user-email\"')) LIKE ? ESCAPE '='", [$escapedPattern]); + } + } + })->get(); + } +} diff --git a/app/Console/Commands/UpdateDisposableDomains.php b/app/Console/Commands/UpdateDisposableDomains.php new file mode 100644 index 00000000..7b4e9d13 --- /dev/null +++ b/app/Console/Commands/UpdateDisposableDomains.php @@ -0,0 +1,42 @@ +info('Downloading latest disposable email domains list...'); + $count = $service->updateDisposableDomains(); + + if ($count > 0) { + $this->info("Successfully updated disposable email domains list! Cached {$count} domains."); + + return self::SUCCESS; + } + + $this->error('Failed to update disposable email domains. Falling back to cached list.'); + + return self::FAILURE; + } +} diff --git a/app/Http/Controllers/Api/AuthenticatedApiController.php b/app/Http/Controllers/Api/AuthenticatedApiController.php index a4e824ec..bea51ee3 100644 --- a/app/Http/Controllers/Api/AuthenticatedApiController.php +++ b/app/Http/Controllers/Api/AuthenticatedApiController.php @@ -16,6 +16,7 @@ use App\Models\PolydockStoreApp; use App\Models\User; use App\Models\UserGroup; +use App\Rules\BannedEmail; use App\Support\EnumHelper; use FreedomtechHosting\PolydockApp\Enums\PolydockAppInstanceStatus; use Illuminate\Http\JsonResponse; @@ -448,7 +449,7 @@ public function getInstances(Request $request): JsonResponse public function createInstance(Request $request): JsonResponse { $request->validate([ - 'email' => 'required|email', + 'email' => ['required', 'email', new BannedEmail(detailed: true)], 'first_name' => 'nullable|string|max:255', 'last_name' => 'nullable|string|max:255', 'storeAppId' => 'required|string|exists:polydock_store_apps,uuid', diff --git a/app/Http/Controllers/Api/RegisterController.php b/app/Http/Controllers/Api/RegisterController.php index 881fdbc0..9dcc1be6 100644 --- a/app/Http/Controllers/Api/RegisterController.php +++ b/app/Http/Controllers/Api/RegisterController.php @@ -6,11 +6,13 @@ use App\Http\Controllers\Controller; use App\Models\PolydockAppInstance; use App\Models\UserRemoteRegistration; +use App\Rules\BannedEmail; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Validator; class RegisterController extends Controller { @@ -38,6 +40,23 @@ class RegisterController extends Controller public function processRegister(Request $request): JsonResponse { Log::info('Processing register request', ['request' => $request->all()]); + + $validator = Validator::make($request->all(), [ + 'email' => ['required', 'email', new BannedEmail], + ]); + + if ($validator->fails()) { + Log::warning('Registration request blocked by validation', [ + 'email' => $request->input('email'), + 'errors' => $validator->errors()->toArray(), + ]); + + return response()->json([ + 'status' => UserRemoteRegistrationStatusEnum::FAILED->value, + 'message' => $validator->errors()->first('email'), + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + try { $registration = UserRemoteRegistration::create([ 'email' => $request->input('email'), diff --git a/app/Models/PolydockBannedPattern.php b/app/Models/PolydockBannedPattern.php new file mode 100644 index 00000000..44aecaa5 --- /dev/null +++ b/app/Models/PolydockBannedPattern.php @@ -0,0 +1,36 @@ + + */ + protected $fillable = [ + 'pattern', + 'reason', + ]; +} diff --git a/app/Rules/BannedEmail.php b/app/Rules/BannedEmail.php new file mode 100644 index 00000000..4ffc3372 --- /dev/null +++ b/app/Rules/BannedEmail.php @@ -0,0 +1,36 @@ +checkEmail($value); + + if ($result->isBlocked()) { + if ($this->detailed) { + $fail($result->getDetailedErrorMessage()); + } else { + $fail($result->getErrorMessage()); + } + } + } +} diff --git a/app/Services/EmailBlockerResult.php b/app/Services/EmailBlockerResult.php new file mode 100644 index 00000000..90c25daf --- /dev/null +++ b/app/Services/EmailBlockerResult.php @@ -0,0 +1,51 @@ +isBlocked; + } + + /** + * Get the reason for the block. + */ + public function getReason(): ?string + { + return $this->reason; + } + + /** + * Get the generic, secure user-facing error message. + */ + public function getErrorMessage(): string + { + if (! $this->isBlocked) { + return ''; + } + + return 'The email address has been blocked.'; + } + + /** + * Get the detailed error message containing the ban reason. + */ + public function getDetailedErrorMessage(): string + { + if (! $this->isBlocked) { + return ''; + } + + return "The email address has been blocked: {$this->reason}."; + } +} diff --git a/app/Services/EmailBlockerService.php b/app/Services/EmailBlockerService.php new file mode 100644 index 00000000..245693cd --- /dev/null +++ b/app/Services/EmailBlockerService.php @@ -0,0 +1,163 @@ +first(); + if ($bannedPattern) { + return new EmailBlockerResult( + true, + $bannedPattern->reason ?: 'Manually banned' + ); + } + + // 2. Check disposable email domains list + $emailParts = explode('@', $email); + $domain = $emailParts[1] ?? ''; + + if (! empty($domain)) { + $disposableDomains = $this->loadDisposableDomains(); + $domainHierarchy = $this->getDomainHierarchy($domain); + + foreach ($domainHierarchy as $subDomain) { + if (isset($disposableDomains[$subDomain])) { + return new EmailBlockerResult(true, 'Disposable email address'); + } + } + } + + return new EmailBlockerResult(false); + } + + /** + * Update disposable domains list from the GitHub source. + */ + public function updateDisposableDomains(): int + { + try { + $url = 'https://raw.githubusercontent.com/disposable/disposable-email-domains/master/index.json'; + $response = Http::timeout(10)->get($url); + + if ($response->successful()) { + $domains = $response->json(); + + if (is_array($domains) && ! empty($domains)) { + $this->saveDisposableDomains($domains); + Log::info('Successfully updated disposable email domains list', ['count' => count($domains)]); + + return count($domains); + } + } + + Log::warning('Failed to update disposable email domains list: response not successful or malformed JSON.'); + } catch (\Exception $e) { + Log::error('Exception triggered while updating disposable email domains', ['message' => $e->getMessage()]); + } + + return 0; + } + + /** + * Load disposable domains from local storage fallback. + */ + private function loadDisposableDomains(): array + { + if ($this->disposableDomainsCache !== null) { + return $this->disposableDomainsCache; + } + + $path = storage_path('app/'.self::FILE_NAME); + + if (! file_exists($path)) { + $this->disposableDomainsCache = []; + + return []; + } + + $content = @file_get_contents($path); + + if ($content === false) { + Log::error('Failed to load disposable domains from storage: file could not be read.', ['path' => $path]); + $this->disposableDomainsCache = []; + + return []; + } + + $domains = json_decode($content, true); + + if (! is_array($domains)) { + Log::error('Failed to load disposable domains from storage: malformed JSON.', ['path' => $path]); + $this->disposableDomainsCache = []; + + return []; + } + + $this->disposableDomainsCache = array_fill_keys($domains, true); + + return $this->disposableDomainsCache; + } + + /** + * Save disposable domains to local storage. + */ + private function saveDisposableDomains(array $domains): void + { + $path = storage_path('app/'.self::FILE_NAME); + + $json = json_encode(array_values(array_unique($domains)), JSON_PRETTY_PRINT); + + if ($json === false) { + Log::error('Failed to encode disposable domains to JSON.'); + + return; + } + + $result = @file_put_contents($path, $json); + + if ($result === false) { + Log::error('Failed to write disposable domains to storage.', ['path' => $path]); + } else { + $this->disposableDomainsCache = array_fill_keys($domains, true); + } + } + + /** + * Get domain hierarchy for checking parent domains (e.g. sub.domain.com -> [sub.domain.com, domain.com]). + */ + private function getDomainHierarchy(string $domain): array + { + $parts = explode('.', strtolower($domain)); + $hierarchy = []; + + while (count($parts) >= 2) { + $hierarchy[] = implode('.', $parts); + array_shift($parts); + } + + return $hierarchy; + } +} diff --git a/database/migrations/2026_06_16_231500_create_polydock_banned_patterns_table.php b/database/migrations/2026_06_16_231500_create_polydock_banned_patterns_table.php new file mode 100644 index 00000000..4f43d2f9 --- /dev/null +++ b/database/migrations/2026_06_16_231500_create_polydock_banned_patterns_table.php @@ -0,0 +1,29 @@ +id(); + $table->string('pattern')->unique(); + $table->string('reason')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('polydock_banned_patterns'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 4370952e..36dbd5e4 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -81,7 +81,10 @@ public function run(): void ]); } - $deployKey = file_get_contents(config('polydock.lagoon_deploy_private_key_file')); + $deployKeyFile = config('polydock.lagoon_deploy_private_key_file'); + $deployKey = (is_string($deployKeyFile) && file_exists($deployKeyFile)) + ? file_get_contents($deployKeyFile) + : 'mock-deploy-private-key-for-testing'; // Create the stores $usaStore = PolydockStore::create([ diff --git a/tests/Feature/Console/Commands/BanEmailsCommandTest.php b/tests/Feature/Console/Commands/BanEmailsCommandTest.php new file mode 100644 index 00000000..c2df005a --- /dev/null +++ b/tests/Feature/Console/Commands/BanEmailsCommandTest.php @@ -0,0 +1,374 @@ +create(); + + return PolydockStoreApp::factory()->create([ + 'polydock_store_id' => $store->id, + ]); + } + + private function createAppInstance( + PolydockStoreApp $storeApp, + UserGroup $userGroup, + string $email, + PolydockAppInstanceStatus $status = PolydockAppInstanceStatus::RUNNING_HEALTHY_CLAIMED + ): PolydockAppInstance { + $instance = new PolydockAppInstance; + $instance->uuid = 'test-'.uniqid(); + $instance->polydock_store_app_id = $storeApp->id; + $instance->user_group_id = $userGroup->id; + $instance->name = 'test-instance-'.uniqid(); + $instance->status = $status; + $instance->app_type = 'test_app_type'; + $instance->data = ['user-email' => $email]; + $instance->saveQuietly(); + + return $instance; + } + + public function test_normalization_of_input_patterns(): void + { + $this->artisan('polydock:ban', [ + 'patterns' => ['spammer@gmail.com', '@spam.com', 'spam.ru'], + '--dry-run' => true, + ]) + ->expectsOutput('Normalized patterns to ban:') + ->expectsOutput(' - spammer@gmail.com') + ->expectsOutput(' - *@spam.com') + ->expectsOutput(' - *@*.spam.com') + ->expectsOutput(' - *@spam.ru') + ->expectsOutput(' - *@*.spam.ru') + ->assertSuccessful(); + } + + public function test_dry_run_does_not_mutate_database(): void + { + $storeApp = $this->createStoreApp(); + + $user = User::factory()->create(['email' => 'spammer@spam.com']); + $group = UserGroup::factory()->create(); + $user->groups()->attach($group, ['role' => UserGroupRoleEnum::OWNER->value]); + + $registration = UserRemoteRegistration::create([ + 'email' => 'spammer@spam.com', + 'status' => UserRemoteRegistrationStatusEnum::PENDING, + 'request_data' => [], + ]); + + $instance = $this->createAppInstance($storeApp, $group, 'spammer@spam.com'); + + $this->artisan('polydock:ban', [ + 'patterns' => ['@spam.com'], + '--dry-run' => true, + ]) + ->assertSuccessful(); + + // Database should be unchanged + $this->assertDatabaseMissing('polydock_banned_patterns', [ + 'pattern' => '*@spam.com', + ]); + $this->assertDatabaseHas('users', ['id' => $user->id]); + $this->assertDatabaseHas('user_groups', ['id' => $group->id]); + + $registration->refresh(); + $this->assertEquals(UserRemoteRegistrationStatusEnum::PENDING, $registration->status); + + $instance->refresh(); + $this->assertEquals(PolydockAppInstanceStatus::RUNNING_HEALTHY_CLAIMED, $instance->status); + } + + public function test_exact_email_ban_cleanup(): void + { + $storeApp = $this->createStoreApp(); + + $user = User::factory()->create(['email' => 'spammer@gmail.com']); + $group = UserGroup::factory()->create(); + $user->groups()->attach($group, ['role' => UserGroupRoleEnum::OWNER->value]); + + $registration = UserRemoteRegistration::create([ + 'email' => 'spammer@gmail.com', + 'status' => UserRemoteRegistrationStatusEnum::PENDING, + 'request_data' => [], + ]); + + $instance = $this->createAppInstance($storeApp, $group, 'spammer@gmail.com'); + + $this->artisan('polydock:ban', [ + 'patterns' => ['spammer@gmail.com'], + '--force' => true, + ]) + ->assertSuccessful(); + + // Ban pattern is registered + $this->assertDatabaseHas('polydock_banned_patterns', [ + 'pattern' => 'spammer@gmail.com', + ]); + + // User deleted + $this->assertDatabaseMissing('users', ['id' => $user->id]); + + // Group was only occupied by the deleted user, so it must be deleted + $this->assertDatabaseMissing('user_groups', ['id' => $group->id]); + + // Registration failed + $registration->refresh(); + $this->assertEquals(UserRemoteRegistrationStatusEnum::FAILED, $registration->status); + + // App instance marked for removal with force purge + $instance->refresh(); + $this->assertEquals(PolydockAppInstanceStatus::PENDING_PRE_REMOVE, $instance->status); + $this->assertNotNull($instance->force_purge_requested_at); + } + + public function test_domain_level_ban_cleanup_with_wildcards(): void + { + $storeApp = $this->createStoreApp(); + + $user1 = User::factory()->create(['email' => 'spammer1@spam.com']); + $user2 = User::factory()->create(['email' => 'spammer2@sub.spam.com']); + $safeUser = User::factory()->create(['email' => 'safe@gmail.com']); + + $group1 = UserGroup::factory()->create(); + $user1->groups()->attach($group1, ['role' => UserGroupRoleEnum::OWNER->value]); + + $group2 = UserGroup::factory()->create(); + $user2->groups()->attach($group2, ['role' => UserGroupRoleEnum::OWNER->value]); + $safeUser->groups()->attach($group2, ['role' => UserGroupRoleEnum::MEMBER->value]); + + $registration1 = UserRemoteRegistration::create([ + 'email' => 'any@spam.com', + 'status' => UserRemoteRegistrationStatusEnum::PENDING, + 'request_data' => [], + ]); + + $registration2 = UserRemoteRegistration::create([ + 'email' => 'any@sub.spam.com', + 'status' => UserRemoteRegistrationStatusEnum::PENDING, + 'request_data' => [], + ]); + + $instance1 = $this->createAppInstance($storeApp, $group1, 'spammer1@spam.com'); + $instance2 = $this->createAppInstance($storeApp, $group2, 'spammer2@sub.spam.com'); + + $this->artisan('polydock:ban', [ + 'patterns' => ['@spam.com'], + '--force' => true, + ]) + ->assertSuccessful(); + + // 1. Both patterns added to database + $this->assertDatabaseHas('polydock_banned_patterns', [ + 'pattern' => '*@spam.com', + ]); + $this->assertDatabaseHas('polydock_banned_patterns', [ + 'pattern' => '*@*.spam.com', + ]); + + // 2. Both spammer users deleted, safe user stays + $this->assertDatabaseMissing('users', ['id' => $user1->id]); + $this->assertDatabaseMissing('users', ['id' => $user2->id]); + $this->assertDatabaseHas('users', ['id' => $safeUser->id]); + + // 3. Group1 should be deleted (empty), Group2 should stay because safeUser is still a member + $this->assertDatabaseMissing('user_groups', ['id' => $group1->id]); + $this->assertDatabaseHas('user_groups', ['id' => $group2->id]); + + // 4. Both registrations failed + $registration1->refresh(); + $registration2->refresh(); + $this->assertEquals(UserRemoteRegistrationStatusEnum::FAILED, $registration1->status); + $this->assertEquals(UserRemoteRegistrationStatusEnum::FAILED, $registration2->status); + + // 5. Both app instances set to pending removal and force purge + $instance1->refresh(); + $instance2->refresh(); + $this->assertEquals(PolydockAppInstanceStatus::PENDING_PRE_REMOVE, $instance1->status); + $this->assertNotNull($instance1->force_purge_requested_at); + $this->assertEquals(PolydockAppInstanceStatus::PENDING_PRE_REMOVE, $instance2->status); + $this->assertNotNull($instance2->force_purge_requested_at); + } + + public function test_email_blocker_service_identifies_banned_patterns(): void + { + PolydockBannedPattern::create([ + 'pattern' => '*@spam.com', + 'reason' => 'Domain ban', + ]); + PolydockBannedPattern::create([ + 'pattern' => '*@*.spam.ru', + 'reason' => 'Subdomain Russian ban', + ]); + PolydockBannedPattern::create([ + 'pattern' => 'spammer@gmail.com', + 'reason' => 'Exact user', + ]); + + $service = app(EmailBlockerService::class); + + // Banned exact email + $res = $service->checkEmail('spammer@gmail.com'); + $this->assertTrue($res->isBlocked()); + $this->assertEquals('Exact user', $res->getReason()); + + // Safe email + $res = $service->checkEmail('safe@gmail.com'); + $this->assertFalse($res->isBlocked()); + + // Banned domain + $res = $service->checkEmail('anything@spam.com'); + $this->assertTrue($res->isBlocked()); + $this->assertEquals('Domain ban', $res->getReason()); + + // Safe ending but different domain (avoid false positive) + $res = $service->checkEmail('anything@notspam.com'); + $this->assertFalse($res->isBlocked()); + + // Subdomain of banned Russian domain + $res = $service->checkEmail('user@sub.spam.ru'); + $this->assertTrue($res->isBlocked()); + $this->assertEquals('Subdomain Russian ban', $res->getReason()); + + // Root of Russian domain (which was only banned at subdomain level) + $res = $service->checkEmail('user@spam.ru'); + $this->assertFalse($res->isBlocked()); + } + + public function test_registration_endpoint_blocks_banned_emails(): void + { + PolydockBannedPattern::create([ + 'pattern' => '*@spam.com', + 'reason' => 'Domain ban', + ]); + + $response = $this->postJson('/api/register', [ + 'email' => 'user@spam.com', + ]); + + $response->assertStatus(422) + ->assertJson([ + 'status' => 'failed', + 'message' => 'The email address has been blocked.', + ]); + } + + public function test_authenticated_api_create_instance_blocks_banned_emails(): void + { + $user = User::factory()->create(); + Sanctum::actingAs($user, ['instances.write']); + + PolydockBannedPattern::create([ + 'pattern' => '*@spam.com', + 'reason' => 'Domain ban', + ]); + + $response = $this->postJson('/api/instance', [ + 'email' => 'user@spam.com', + 'storeAppId' => 'some-uuid', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors([ + 'email' => 'The email address has been blocked: Domain ban.', + ]); + } + + public function test_underscore_in_ban_pattern_does_not_block_adjacent_characters(): void + { + PolydockBannedPattern::create([ + 'pattern' => 'spammer_name@gmail.com', + 'reason' => 'Exact underscore user', + ]); + + $service = app(EmailBlockerService::class); + + // Exactly matches the underscore pattern - should be blocked + $this->assertTrue($service->checkEmail('spammer_name@gmail.com')->isBlocked()); + + // Differs by character in underscore position - should not be blocked + $this->assertFalse($service->checkEmail('spammer-name@gmail.com')->isBlocked()); + $this->assertFalse($service->checkEmail('spammer1name@gmail.com')->isBlocked()); + } + + public function test_command_does_not_clean_up_adjacent_emails_with_underscores(): void + { + $targetUser = User::factory()->create(['email' => 'spammer_name@gmail.com']); + $safeUser = User::factory()->create(['email' => 'spammer-name@gmail.com']); + + $group1 = UserGroup::factory()->create(); + $targetUser->groups()->attach($group1, ['role' => UserGroupRoleEnum::OWNER->value]); + + $group2 = UserGroup::factory()->create(); + $safeUser->groups()->attach($group2, ['role' => UserGroupRoleEnum::OWNER->value]); + + $this->artisan('polydock:ban', [ + 'patterns' => ['spammer_name@gmail.com'], + '--force' => true, + ]) + ->assertSuccessful(); + + // Target user deleted, safe user remains + $this->assertDatabaseMissing('users', ['id' => $targetUser->id]); + $this->assertDatabaseHas('users', ['id' => $safeUser->id]); + + // Empty group 1 deleted, group 2 remains + $this->assertDatabaseMissing('user_groups', ['id' => $group1->id]); + $this->assertDatabaseHas('user_groups', ['id' => $group2->id]); + } + + public function test_disposable_email_domain_blocks_registration_successfully(): void + { + $path = storage_path('app/disposable_domains.json'); + $oldContent = file_exists($path) ? file_get_contents($path) : null; + + // Write a test JSON block with our mock disposable domains + $testDomains = ['disposable-junk.com', 'test-spam-domain.org']; + file_put_contents($path, json_encode($testDomains)); + + try { + $service = app(EmailBlockerService::class); + + // Directly check domain blocking + $this->assertTrue($service->checkEmail('user@disposable-junk.com')->isBlocked()); + $this->assertTrue($service->checkEmail('another-user@sub.test-spam-domain.org')->isBlocked()); + $this->assertFalse($service->checkEmail('user@legit-domain.com')->isBlocked()); + } finally { + // Restore any previous content + if ($oldContent !== null) { + file_put_contents($path, $oldContent); + } else { + @unlink($path); + } + } + } +} From 5a1e06abf6c0ad268a4ef7902eea31b23db3204d Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Wed, 17 Jun 2026 01:21:06 +0200 Subject: [PATCH 06/13] feat(forms): implement secure, public hosted iframe forms with BannedEmail rule and status polling --- app/Forms/BaseHostedForm.php | 53 ++ app/Forms/DrupalAIDemoDrupalOrgForm.php | 68 ++ app/Forms/HostedFormInterface.php | 51 ++ app/Http/Controllers/FormController.php | 195 +++++ .../views/forms/drupal-ai-demo.blade.php | 725 ++++++++++++++++++ resources/views/layouts/form-iframe.blade.php | 54 ++ routes/web.php | 4 + .../Controllers/FormControllerTest.php | 164 ++++ 8 files changed, 1314 insertions(+) create mode 100644 app/Forms/BaseHostedForm.php create mode 100644 app/Forms/DrupalAIDemoDrupalOrgForm.php create mode 100644 app/Forms/HostedFormInterface.php create mode 100644 app/Http/Controllers/FormController.php create mode 100644 resources/views/forms/drupal-ai-demo.blade.php create mode 100644 resources/views/layouts/form-iframe.blade.php create mode 100644 tests/Feature/Controllers/FormControllerTest.php diff --git a/app/Forms/BaseHostedForm.php b/app/Forms/BaseHostedForm.php new file mode 100644 index 00000000..003f8578 --- /dev/null +++ b/app/Forms/BaseHostedForm.php @@ -0,0 +1,53 @@ +getTitle().' | Polydock'; + } + + #[\Override] + public function getSeoDescription(): string + { + return 'Provision and try a trial environment instantly with Polydock.'; + } + + #[\Override] + public function getAllowedEmbedDomains(): array + { + return [ + 'amazee.ai', + 'www.amazee.ai', + 'localhost', + ]; + } + + #[\Override] + public function getRecaptchaEnabled(): bool + { + return true; + } + + /** + * Map form submission fields to the schema required by UserRemoteRegistration + */ + #[\Override] + public function transformPayload(array $validatedData): array + { + return [ + 'email' => $validatedData['email'], + 'first_name' => $validatedData['first_name'] ?? '', + 'last_name' => $validatedData['last_name'] ?? '', + 'organization' => $validatedData['organization'] ?? '', + 'job_title' => $validatedData['job_title'] ?? '', + 'register_type' => 'REQUEST_TRIAL', + 'aup_and_privacy_acceptance' => 1, + 'opt_in_to_product_updates' => 1, + 'trial_app' => $validatedData['trial_app'], + ]; + } +} diff --git a/app/Forms/DrupalAIDemoDrupalOrgForm.php b/app/Forms/DrupalAIDemoDrupalOrgForm.php new file mode 100644 index 00000000..54b3aaad --- /dev/null +++ b/app/Forms/DrupalAIDemoDrupalOrgForm.php @@ -0,0 +1,68 @@ + ['required', 'string', 'max:100'], + 'last_name' => ['required', 'string', 'max:100'], + 'email' => ['required', 'email', new BannedEmail], + 'organization' => ['nullable', 'string', 'max:150'], + 'job_title' => ['nullable', 'string', 'max:150'], + 'country' => ['nullable', 'string'], + 'stage_in_ai_adoption' => ['nullable', 'string', 'in:just-curious,specific-need,already-using'], + 'interest_in_drupal_ai' => ['nullable', 'string', 'max:255'], + 'trial_app' => ['required', 'uuid'], + ]; + } + + #[\Override] + public function transformPayload(array $validatedData): array + { + $payload = parent::transformPayload($validatedData); + + // Add custom properties specific to the Drupal AI demo setup + $payload['company_name'] = $validatedData['organization'] ?? ''; + $payload['instance_config_stage_in_ai_adoption'] = $validatedData['stage_in_ai_adoption'] ?? ''; + $payload['instance_config_interest_in_drupal_ai'] = $validatedData['interest_in_drupal_ai'] ?? ''; + $payload['instance_config_country'] = $validatedData['country'] ?? ''; + + return $payload; + } +} diff --git a/app/Forms/HostedFormInterface.php b/app/Forms/HostedFormInterface.php new file mode 100644 index 00000000..93a29ecc --- /dev/null +++ b/app/Forms/HostedFormInterface.php @@ -0,0 +1,51 @@ + DrupalAIDemoDrupalOrgForm::class, + ]; + + if (! isset($forms[$slug])) { + return null; + } + + return app($forms[$slug]); + } + + /** + * Display the hosted iframe form. + */ + public function show(string $formSlug, Request $request): Response + { + $form = $this->getFormBySlug($formSlug); + + if (! $form) { + abort(404, 'Form not found.'); + } + + // Fetch public stores with available trial apps + $regions = PolydockStore::query() + ->where('status', PolydockStoreStatusEnum::PUBLIC) + ->with(['apps' => function ($query) { + $query->where('status', PolydockStoreAppStatusEnum::AVAILABLE) + ->where('available_for_trials', true); + }]) + ->get(); + + $regionsData = $regions->map(fn ($store) => [ + 'id' => $store->id, + 'name' => $store->name, + 'apps' => $store->apps->map(fn ($app) => [ + 'uuid' => $app->uuid, + 'name' => $app->name, + ]), + ]); + + $viewName = $form->getViewName(); + + if (! view()->exists($viewName)) { + abort(500, "View [{$viewName}] not found for form."); + } + + $response = response()->view($viewName, [ + 'form' => $form, + 'regions' => $regions, + 'regionsData' => $regionsData, + 'recaptchaSiteKey' => config('services.recaptcha.sitekey') ?? env('NOCAPTCHA_SITEKEY'), + ]); + + // Inject secure framing headers based on allowed domains + $domains = implode(' ', $form->getAllowedEmbedDomains()); + $response->headers->remove('X-Frame-Options'); + $response->headers->set('Content-Security-Policy', "frame-ancestors 'self' {$domains}"); + + return $response; + } + + /** + * Submit and process the hosted iframe form. + */ + public function submit(string $formSlug, Request $request): JsonResponse + { + $form = $this->getFormBySlug($formSlug); + + if (! $form) { + return response()->json([ + 'status' => 'error', + 'message' => 'Form not found.', + ], Response::HTTP_NOT_FOUND); + } + + // Perform standard request validation based on Form definitions + $validator = Validator::make($request->all(), $form->getValidationRules()); + + if ($validator->fails()) { + return response()->json([ + 'status' => 'error', + 'message' => $validator->errors()->first(), + 'errors' => $validator->errors()->toArray(), + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + // Handle reCAPTCHA verification if enabled + if ($form->getRecaptchaEnabled()) { + $recaptchaToken = $request->input('recaptcha'); + + if (! $recaptchaToken) { + return response()->json([ + 'status' => 'error', + 'message' => 'Please verify that you are not a robot.', + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + $secretKey = config('services.recaptcha.secret') ?? env('NOCAPTCHA_SECRET'); + + try { + $recaptchaResponse = Http::asForm()->post('https://www.google.com/recaptcha/api/siteverify', [ + 'secret' => $secretKey, + 'response' => $recaptchaToken, + 'remoteip' => $request->ip(), + ]); + + if (! $recaptchaResponse->json('success')) { + Log::warning('reCAPTCHA verification failed for hosted form', [ + 'form' => $formSlug, + 'ip' => $request->ip(), + 'response' => $recaptchaResponse->json(), + ]); + + return response()->json([ + 'status' => 'error', + 'message' => 'reCAPTCHA verification failed. Please try again.', + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + } catch (\Exception $e) { + Log::error('reCAPTCHA communication error during form submit', [ + 'form' => $formSlug, + 'error' => $e->getMessage(), + ]); + + // Fallback graceful check in dev environment to allow offline testing + if (app()->environment('production', 'prod')) { + return response()->json([ + 'status' => 'error', + 'message' => 'Unable to verify reCAPTCHA. Please try again later.', + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } + } + } + + // Transform form data to match UserRemoteRegistration structure + $payload = $form->transformPayload($validator->validated()); + + try { + // Create the remote registration model which dispatches async provisioning + $registration = UserRemoteRegistration::create([ + 'email' => $payload['email'], + 'request_data' => $payload, + 'status' => UserRemoteRegistrationStatusEnum::PENDING, + ]); + + Log::info('Created UserRemoteRegistration via hosted form', [ + 'form' => $formSlug, + 'registration_id' => $registration->id, + 'uuid' => $registration->uuid, + ]); + + return response()->json([ + 'status' => UserRemoteRegistrationStatusEnum::PENDING->value, + 'message' => 'Registration pending', + 'id' => $registration->uuid, + ], Response::HTTP_ACCEPTED); + } catch (\Exception $e) { + Log::error('Error creating user remote registration from hosted form', [ + 'form' => $formSlug, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return response()->json([ + 'status' => 'error', + 'message' => 'An unexpected error occurred. Please try again later.', + ], Response::HTTP_INTERNAL_SERVER_ERROR); + } + } +} diff --git a/resources/views/forms/drupal-ai-demo.blade.php b/resources/views/forms/drupal-ai-demo.blade.php new file mode 100644 index 00000000..cafd5ece --- /dev/null +++ b/resources/views/forms/drupal-ai-demo.blade.php @@ -0,0 +1,725 @@ +@extends('layouts.form-iframe') + +@section('title', $form->getSeoTitle()) +@section('seo_description', $form->getSeoDescription()) + +@section('styles') + + +@endsection + +@section('content') +
+ +
+ + +
+ @csrf + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + + +
+

+ By participating in this trial, you acknowledge that you have read, understood, and + agree to the terms of this consent + and you grant permission to the Drupal Association and amazee.ai + to share your personal information + for the purposes of facilitating your participation in the trial and the Purpose set forth therein. +

+

+ By participating in this trial, you acknowledge that you have read, understood, and + agree to the privacy policies of both the Drupal Association + and our Trial Partner, amazee.ai. +

+
+ + +
+
+
+ + +
+ + + + + + +
+@endsection + +@section('scripts') + +@endsection diff --git a/resources/views/layouts/form-iframe.blade.php b/resources/views/layouts/form-iframe.blade.php new file mode 100644 index 00000000..d9996857 --- /dev/null +++ b/resources/views/layouts/form-iframe.blade.php @@ -0,0 +1,54 @@ + + + + + + @yield('title', $form->getSeoTitle()) + + + + + + + + + + + @yield('styles') + + + @yield('content') + + + + @yield('scripts') + + diff --git a/routes/web.php b/routes/web.php index 4c36c0af..08eb16c1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,6 @@ name('app-instances.show')->middleware('signed'); + +Route::get('/f/{formSlug}', [FormController::class, 'show'])->name('forms.show'); +Route::post('/f/{formSlug}', [FormController::class, 'submit'])->name('forms.submit'); diff --git a/tests/Feature/Controllers/FormControllerTest.php b/tests/Feature/Controllers/FormControllerTest.php new file mode 100644 index 00000000..900959ed --- /dev/null +++ b/tests/Feature/Controllers/FormControllerTest.php @@ -0,0 +1,164 @@ + function ($request) { + $responseToken = $request['response'] ?? ''; + if ($responseToken === 'invalid-token') { + return Http::response(['success' => false]); + } + + return Http::response(['success' => true]); + }, + ]); + } + + /** @test */ + public function it_aborts_with_404_for_unknown_form_slugs() + { + $response = $this->get('/f/unknown-form-slug'); + + $response->assertStatus(404); + } + + /** @test */ + public function it_renders_the_hosted_form_correctly_with_security_headers() + { + // Create sample store and app + $store = PolydockStore::create([ + 'name' => 'Europe Store', + 'status' => PolydockStoreStatusEnum::PUBLIC, + 'listed_in_marketplace' => true, + 'lagoon_deploy_region_id_ext' => '1', + 'lagoon_deploy_project_prefix' => 'ft-eu', + 'lagoon_deploy_organization_id_ext' => '123', + ]); + + $app = PolydockStoreApp::create([ + 'polydock_store_id' => $store->id, + 'name' => 'CKEditor Demo', + 'polydock_app_class' => 'App\\PolydockApp', + 'lagoon_deploy_git' => 'git@github.com:example/app.git', + 'lagoon_deploy_branch' => 'main', + 'status' => PolydockStoreAppStatusEnum::AVAILABLE, + 'available_for_trials' => true, + 'support_email' => 'support@example.com', + 'author' => 'Test Author', + 'description' => 'Test Description', + ]); + + $response = $this->get('/f/drupal-ai-demo'); + + $response->assertStatus(200); + $response->assertViewIs('forms.drupal-ai-demo'); + $response->assertViewHas('form'); + $response->assertViewHas('regions'); + + // Check framing security headers are set properly + $response->assertHeaderMissing('X-Frame-Options'); + $response->assertHeader('Content-Security-Policy', "frame-ancestors 'self' amazee.ai www.amazee.ai localhost"); + } + + /** @test */ + public function it_fails_submitting_form_with_missing_fields() + { + $response = $this->postJson('/f/drupal-ai-demo', [ + 'first_name' => '', + 'last_name' => 'Doe', + 'email' => 'invalid-email', + 'trial_app' => '', + ]); + + $response->assertStatus(422); + $response->assertJsonStructure([ + 'status', + 'message', + 'errors', + ]); + } + + /** @test */ + public function it_fails_if_recaptcha_verification_fails() + { + $response = $this->postJson('/f/drupal-ai-demo', [ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'email' => 'john.doe@example.com', + 'trial_app' => '3aba2790-b1e9-47d4-b86d-06ffa4790895', + 'recaptcha' => 'invalid-token', + ]); + + $response->assertStatus(422); + $response->assertJson([ + 'status' => 'error', + 'message' => 'reCAPTCHA verification failed. Please try again.', + ]); + } + + /** @test */ + public function it_successfully_submits_and_registers_user_trial() + { + $response = $this->postJson('/f/drupal-ai-demo', [ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'email' => 'john.doe@example.com', + 'organization' => 'Acme Corp', + 'job_title' => 'Web Developer', + 'country' => 'United States', + 'stage_in_ai_adoption' => 'just-curious', + 'interest_in_drupal_ai' => 'General testing', + 'trial_app' => '3aba2790-b1e9-47d4-b86d-06ffa4790895', + 'recaptcha' => 'valid-mock-token', + ]); + + $response->assertStatus(202); + $response->assertJson([ + 'status' => 'pending', + 'message' => 'Registration pending', + ]); + $response->assertJsonStructure(['id']); + + // Assert UserRemoteRegistration model was created + $this->assertDatabaseHas('user_remote_registrations', [ + 'email' => 'john.doe@example.com', + 'status' => UserRemoteRegistrationStatusEnum::PENDING->value, + ]); + + $registration = UserRemoteRegistration::first(); + $this->assertNotNull($registration->uuid); + + // Verify request payload mappings + $this->assertEquals('John', $registration->getRequestValue('first_name')); + $this->assertEquals('Doe', $registration->getRequestValue('last_name')); + $this->assertEquals('Acme Corp', $registration->getRequestValue('company_name')); + $this->assertEquals('just-curious', $registration->getRequestValue('instance_config_stage_in_ai_adoption')); + $this->assertEquals('3aba2790-b1e9-47d4-b86d-06ffa4790895', $registration->getRequestValue('trial_app')); + + // Verify registration background job was pushed + Queue::assertPushed(ProcessUserRemoteRegistration::class); + } +} From e01c3a9a342e34d47dec408457e98987b1376e60 Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Wed, 17 Jun 2026 01:36:11 +0200 Subject: [PATCH 07/13] sec(forms): harden security, resolve config caching issue, and enforce strict trial app db validation --- app/Forms/BaseHostedForm.php | 21 +++++++- app/Forms/DrupalAIDemoDrupalOrgForm.php | 10 +++- app/Forms/HostedFormInterface.php | 5 ++ app/Http/Controllers/FormController.php | 10 ++-- config/services.php | 5 ++ .../views/forms/drupal-ai-demo.blade.php | 8 ++- resources/views/layouts/form-iframe.blade.php | 20 ++++--- routes/web.php | 4 +- .../Controllers/FormControllerTest.php | 54 ++++++++++++------- 9 files changed, 102 insertions(+), 35 deletions(-) diff --git a/app/Forms/BaseHostedForm.php b/app/Forms/BaseHostedForm.php index 003f8578..80c79b62 100644 --- a/app/Forms/BaseHostedForm.php +++ b/app/Forms/BaseHostedForm.php @@ -19,11 +19,16 @@ public function getSeoDescription(): string #[\Override] public function getAllowedEmbedDomains(): array { - return [ + $domains = [ 'amazee.ai', 'www.amazee.ai', - 'localhost', ]; + + if (! app()->isProduction()) { + $domains[] = 'localhost'; + } + + return $domains; } #[\Override] @@ -32,6 +37,18 @@ public function getRecaptchaEnabled(): bool return true; } + #[\Override] + public function getAllowedEmbedOrigins(): array + { + return array_map(function ($domain) { + if ($domain === 'localhost') { + return 'http://localhost'; + } + + return "https://{$domain}"; + }, $this->getAllowedEmbedDomains()); + } + /** * Map form submission fields to the schema required by UserRemoteRegistration */ diff --git a/app/Forms/DrupalAIDemoDrupalOrgForm.php b/app/Forms/DrupalAIDemoDrupalOrgForm.php index 54b3aaad..3353669d 100644 --- a/app/Forms/DrupalAIDemoDrupalOrgForm.php +++ b/app/Forms/DrupalAIDemoDrupalOrgForm.php @@ -2,7 +2,9 @@ namespace App\Forms; +use App\Enums\PolydockStoreAppStatusEnum; use App\Rules\BannedEmail; +use Illuminate\Validation\Rule; class DrupalAIDemoDrupalOrgForm extends BaseHostedForm { @@ -48,7 +50,13 @@ public function getValidationRules(): array 'country' => ['nullable', 'string'], 'stage_in_ai_adoption' => ['nullable', 'string', 'in:just-curious,specific-need,already-using'], 'interest_in_drupal_ai' => ['nullable', 'string', 'max:255'], - 'trial_app' => ['required', 'uuid'], + 'trial_app' => [ + 'required', + 'uuid', + Rule::exists('polydock_store_apps', 'uuid') + ->where('status', PolydockStoreAppStatusEnum::AVAILABLE->value) + ->where('available_for_trials', true), + ], ]; } diff --git a/app/Forms/HostedFormInterface.php b/app/Forms/HostedFormInterface.php index 93a29ecc..2c32fa05 100644 --- a/app/Forms/HostedFormInterface.php +++ b/app/Forms/HostedFormInterface.php @@ -44,6 +44,11 @@ public function getRecaptchaEnabled(): bool; */ public function getViewName(): string; + /** + * Get whitelisted parent origins allowed to iframe this form (including protocol). + */ + public function getAllowedEmbedOrigins(): array; + /** * Map the form submission input array to the structure required by UserRemoteRegistration. */ diff --git a/app/Http/Controllers/FormController.php b/app/Http/Controllers/FormController.php index b8b3df56..8f7b8264 100644 --- a/app/Http/Controllers/FormController.php +++ b/app/Http/Controllers/FormController.php @@ -73,13 +73,13 @@ public function show(string $formSlug, Request $request): Response 'form' => $form, 'regions' => $regions, 'regionsData' => $regionsData, - 'recaptchaSiteKey' => config('services.recaptcha.sitekey') ?? env('NOCAPTCHA_SITEKEY'), + 'recaptchaSiteKey' => config('services.recaptcha.sitekey'), ]); - // Inject secure framing headers based on allowed domains - $domains = implode(' ', $form->getAllowedEmbedDomains()); + // Inject secure framing headers based on allowed origins + $origins = implode(' ', $form->getAllowedEmbedOrigins()); $response->headers->remove('X-Frame-Options'); - $response->headers->set('Content-Security-Policy', "frame-ancestors 'self' {$domains}"); + $response->headers->set('Content-Security-Policy', "frame-ancestors 'self' {$origins}"); return $response; } @@ -120,7 +120,7 @@ public function submit(string $formSlug, Request $request): JsonResponse ], Response::HTTP_UNPROCESSABLE_ENTITY); } - $secretKey = config('services.recaptcha.secret') ?? env('NOCAPTCHA_SECRET'); + $secretKey = config('services.recaptcha.secret'); try { $recaptchaResponse = Http::asForm()->post('https://www.google.com/recaptcha/api/siteverify', [ diff --git a/config/services.php b/config/services.php index 949e3442..dabcf02c 100644 --- a/config/services.php +++ b/config/services.php @@ -37,4 +37,9 @@ ], ], + 'recaptcha' => [ + 'sitekey' => env('NOCAPTCHA_SITEKEY') ?? env('RECAPTCHA_SITEKEY'), + 'secret' => env('NOCAPTCHA_SECRET') ?? env('RECAPTCHA_SECRET'), + ], + ]; diff --git a/resources/views/forms/drupal-ai-demo.blade.php b/resources/views/forms/drupal-ai-demo.blade.php index cafd5ece..a2258132 100644 --- a/resources/views/forms/drupal-ai-demo.blade.php +++ b/resources/views/forms/drupal-ai-demo.blade.php @@ -678,8 +678,12 @@ function checkStatus() { }) .catch(err => { console.error('Polling error:', err); - // Continue polling despite minor network hiccups - setTimeout(checkStatus, pollInterval); + // Continue polling despite minor network hiccups, but still respect the timeout + if (Date.now() - startTime < timeout) { + setTimeout(checkStatus, pollInterval); + } else { + renderDelayScreen(); + } }); } diff --git a/resources/views/layouts/form-iframe.blade.php b/resources/views/layouts/form-iframe.blade.php index d9996857..03a70a6e 100644 --- a/resources/views/layouts/form-iframe.blade.php +++ b/resources/views/layouts/form-iframe.blade.php @@ -22,12 +22,16 @@ @yield('scripts') diff --git a/routes/web.php b/routes/web.php index 08eb16c1..f8b77c96 100644 --- a/routes/web.php +++ b/routes/web.php @@ -25,4 +25,6 @@ })->name('app-instances.show')->middleware('signed'); Route::get('/f/{formSlug}', [FormController::class, 'show'])->name('forms.show'); -Route::post('/f/{formSlug}', [FormController::class, 'submit'])->name('forms.submit'); +Route::post('/f/{formSlug}', [FormController::class, 'submit']) + ->name('forms.submit') + ->middleware('throttle:10,1'); diff --git a/tests/Feature/Controllers/FormControllerTest.php b/tests/Feature/Controllers/FormControllerTest.php index 900959ed..8f6cca36 100644 --- a/tests/Feature/Controllers/FormControllerTest.php +++ b/tests/Feature/Controllers/FormControllerTest.php @@ -18,6 +18,9 @@ class FormControllerTest extends TestCase { use RefreshDatabase; + protected PolydockStoreApp $storeApp; + + #[\Override] protected function setUp(): void { parent::setUp(); @@ -35,20 +38,8 @@ protected function setUp(): void return Http::response(['success' => true]); }, ]); - } - - /** @test */ - public function it_aborts_with_404_for_unknown_form_slugs() - { - $response = $this->get('/f/unknown-form-slug'); - - $response->assertStatus(404); - } - /** @test */ - public function it_renders_the_hosted_form_correctly_with_security_headers() - { - // Create sample store and app + // Create sample public store and available trial app in the database $store = PolydockStore::create([ 'name' => 'Europe Store', 'status' => PolydockStoreStatusEnum::PUBLIC, @@ -58,7 +49,7 @@ public function it_renders_the_hosted_form_correctly_with_security_headers() 'lagoon_deploy_organization_id_ext' => '123', ]); - $app = PolydockStoreApp::create([ + $this->storeApp = PolydockStoreApp::create([ 'polydock_store_id' => $store->id, 'name' => 'CKEditor Demo', 'polydock_app_class' => 'App\\PolydockApp', @@ -70,7 +61,19 @@ public function it_renders_the_hosted_form_correctly_with_security_headers() 'author' => 'Test Author', 'description' => 'Test Description', ]); + } + + /** @test */ + public function it_aborts_with_404_for_unknown_form_slugs() + { + $response = $this->get('/f/unknown-form-slug'); + $response->assertStatus(404); + } + + /** @test */ + public function it_renders_the_hosted_form_correctly_with_security_headers() + { $response = $this->get('/f/drupal-ai-demo'); $response->assertStatus(200); @@ -80,7 +83,7 @@ public function it_renders_the_hosted_form_correctly_with_security_headers() // Check framing security headers are set properly $response->assertHeaderMissing('X-Frame-Options'); - $response->assertHeader('Content-Security-Policy', "frame-ancestors 'self' amazee.ai www.amazee.ai localhost"); + $response->assertHeader('Content-Security-Policy', "frame-ancestors 'self' https://amazee.ai https://www.amazee.ai http://localhost"); } /** @test */ @@ -108,7 +111,7 @@ public function it_fails_if_recaptcha_verification_fails() 'first_name' => 'John', 'last_name' => 'Doe', 'email' => 'john.doe@example.com', - 'trial_app' => '3aba2790-b1e9-47d4-b86d-06ffa4790895', + 'trial_app' => $this->storeApp->uuid, 'recaptcha' => 'invalid-token', ]); @@ -131,7 +134,7 @@ public function it_successfully_submits_and_registers_user_trial() 'country' => 'United States', 'stage_in_ai_adoption' => 'just-curious', 'interest_in_drupal_ai' => 'General testing', - 'trial_app' => '3aba2790-b1e9-47d4-b86d-06ffa4790895', + 'trial_app' => $this->storeApp->uuid, 'recaptcha' => 'valid-mock-token', ]); @@ -156,9 +159,24 @@ public function it_successfully_submits_and_registers_user_trial() $this->assertEquals('Doe', $registration->getRequestValue('last_name')); $this->assertEquals('Acme Corp', $registration->getRequestValue('company_name')); $this->assertEquals('just-curious', $registration->getRequestValue('instance_config_stage_in_ai_adoption')); - $this->assertEquals('3aba2790-b1e9-47d4-b86d-06ffa4790895', $registration->getRequestValue('trial_app')); + $this->assertEquals($this->storeApp->uuid, $registration->getRequestValue('trial_app')); // Verify registration background job was pushed Queue::assertPushed(ProcessUserRemoteRegistration::class); } + + /** @test */ + public function it_rejects_submitting_form_with_invalid_trial_app_uuid() + { + $response = $this->postJson('/f/drupal-ai-demo', [ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'email' => 'john.doe@example.com', + 'trial_app' => '00000000-0000-0000-0000-000000000000', // Valid UUID structure but non-existent app + 'recaptcha' => 'valid-mock-token', + ]); + + $response->assertStatus(422); + $this->assertStringContainsString('selected trial app is invalid', $response->json('message')); + } } From 46bd5e6cc1d3d75543bef854918abcbeb6ce23d6 Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Wed, 17 Jun 2026 08:42:26 +0200 Subject: [PATCH 08/13] chore: fix email blocker disposable domains json url --- app/Services/EmailBlockerService.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/Services/EmailBlockerService.php b/app/Services/EmailBlockerService.php index 245693cd..47959913 100644 --- a/app/Services/EmailBlockerService.php +++ b/app/Services/EmailBlockerService.php @@ -59,17 +59,17 @@ public function checkEmail(string $email): EmailBlockerResult public function updateDisposableDomains(): int { try { - $url = 'https://raw.githubusercontent.com/disposable/disposable-email-domains/master/index.json'; + $url = 'https://raw.githubusercontent.com/disposable/disposable-email-domains/refs/heads/master/domains.json'; $response = Http::timeout(10)->get($url); if ($response->successful()) { $domains = $response->json(); - if (is_array($domains) && ! empty($domains)) { + if (\is_array($domains) && ! empty($domains)) { $this->saveDisposableDomains($domains); - Log::info('Successfully updated disposable email domains list', ['count' => count($domains)]); + Log::info('Successfully updated disposable email domains list', ['count' => \count($domains)]); - return count($domains); + return \count($domains); } } @@ -109,7 +109,7 @@ private function loadDisposableDomains(): array $domains = json_decode($content, true); - if (! is_array($domains)) { + if (! \is_array($domains)) { Log::error('Failed to load disposable domains from storage: malformed JSON.', ['path' => $path]); $this->disposableDomainsCache = []; @@ -153,7 +153,7 @@ private function getDomainHierarchy(string $domain): array $parts = explode('.', strtolower($domain)); $hierarchy = []; - while (count($parts) >= 2) { + while (\count($parts) >= 2) { $hierarchy[] = implode('.', $parts); array_shift($parts); } From 64f14b5d146f39ed8447ca230c31c064f9e544dc Mon Sep 17 00:00:00 2001 From: Dan Lemon Date: Wed, 17 Jun 2026 10:13:57 +0200 Subject: [PATCH 09/13] chore: country list --- .env.example | 4 + app/Forms/BaseHostedForm.php | 14 ++- app/Forms/DrupalAIDemoDrupalOrgForm.php | 6 +- app/Http/Controllers/FormController.php | 5 +- composer.json | 1 + composer.lock | 111 ++++++++++++++---- config/services.php | 1 + .../views/forms/drupal-ai-demo.blade.php | 43 +++---- .../Controllers/FormControllerTest.php | 109 ++++++++++++++++- 9 files changed, 238 insertions(+), 56 deletions(-) diff --git a/.env.example b/.env.example index bab36d51..348ccc26 100644 --- a/.env.example +++ b/.env.example @@ -77,3 +77,7 @@ MEILISEARCH_HOST=http://meilisearch:7700 MEILISEARCH_NO_ANALYTICS=false POLYDOCK_HEALTH_TOKEN=change-me-to-a-secure-random-string + +RECAPTCHA_ENABLED=true +RECAPTCHA_SITEKEY= +RECAPTCHA_SECRET= diff --git a/app/Forms/BaseHostedForm.php b/app/Forms/BaseHostedForm.php index 80c79b62..321e33fc 100644 --- a/app/Forms/BaseHostedForm.php +++ b/app/Forms/BaseHostedForm.php @@ -34,19 +34,23 @@ public function getAllowedEmbedDomains(): array #[\Override] public function getRecaptchaEnabled(): bool { - return true; + return (bool) config('services.recaptcha.enabled', true); } #[\Override] public function getAllowedEmbedOrigins(): array { - return array_map(function ($domain) { + $origins = []; + foreach ($this->getAllowedEmbedDomains() as $domain) { if ($domain === 'localhost') { - return 'http://localhost'; + $origins[] = 'http://localhost'; + $origins[] = 'http://localhost:*'; + } else { + $origins[] = "https://{$domain}"; } + } - return "https://{$domain}"; - }, $this->getAllowedEmbedDomains()); + return $origins; } /** diff --git a/app/Forms/DrupalAIDemoDrupalOrgForm.php b/app/Forms/DrupalAIDemoDrupalOrgForm.php index 3353669d..561e0acd 100644 --- a/app/Forms/DrupalAIDemoDrupalOrgForm.php +++ b/app/Forms/DrupalAIDemoDrupalOrgForm.php @@ -47,7 +47,11 @@ public function getValidationRules(): array 'email' => ['required', 'email', new BannedEmail], 'organization' => ['nullable', 'string', 'max:150'], 'job_title' => ['nullable', 'string', 'max:150'], - 'country' => ['nullable', 'string'], + 'country' => [ + 'nullable', + 'string', + Rule::in(array_values(__('filament-country-field::country', [], 'en'))), + ], 'stage_in_ai_adoption' => ['nullable', 'string', 'in:just-curious,specific-need,already-using'], 'interest_in_drupal_ai' => ['nullable', 'string', 'max:255'], 'trial_app' => [ diff --git a/app/Http/Controllers/FormController.php b/app/Http/Controllers/FormController.php index 8f7b8264..9ff38b89 100644 --- a/app/Http/Controllers/FormController.php +++ b/app/Http/Controllers/FormController.php @@ -74,6 +74,7 @@ public function show(string $formSlug, Request $request): Response 'regions' => $regions, 'regionsData' => $regionsData, 'recaptchaSiteKey' => config('services.recaptcha.sitekey'), + 'countries' => __('filament-country-field::country', [], 'en'), ]); // Inject secure framing headers based on allowed origins @@ -147,8 +148,8 @@ public function submit(string $formSlug, Request $request): JsonResponse 'error' => $e->getMessage(), ]); - // Fallback graceful check in dev environment to allow offline testing - if (app()->environment('production', 'prod')) { + // Fallback graceful check in local/testing environments to allow offline testing + if (! app()->environment('local', 'testing')) { return response()->json([ 'status' => 'error', 'message' => 'Unable to verify reCAPTCHA. Please try again later.', diff --git a/composer.json b/composer.json index 7579f65b..378ffa65 100644 --- a/composer.json +++ b/composer.json @@ -29,6 +29,7 @@ "laravel/sanctum": "^4.0", "laravel/slack-notification-channel": "^3.8", "laravel/tinker": "^2.9", + "parfaitementweb/filament-country-field": "^2.5", "phpseclib/phpseclib": "^3.0", "spatie/laravel-activitylog": "^4.10", "spatie/laravel-permission": "^6.25", diff --git a/composer.lock b/composer.lock index 25cc9889..6006d027 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a496dd3cbf82b68970a656bceb52b236", + "content-hash": "a7bd6a56e74e7c3a51f94f576880ff85", "packages": [ { "name": "amazeeio/lagoon-logs", @@ -2251,22 +2251,22 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.11.2", + "version": "7.12.0", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "bf5f35ad4b774b9d7c5766c02035e865e7e3fdab" + "reference": "eaa81598031cf57a9e36258c8546defffc994cba" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/bf5f35ad4b774b9d7c5766c02035e865e7e3fdab", - "reference": "bf5f35ad4b774b9d7c5766c02035e865e7e3fdab", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/eaa81598031cf57a9e36258c8546defffc994cba", + "reference": "eaa81598031cf57a9e36258c8546defffc994cba", "shasum": "" }, "require": { "ext-json": "*", "guzzlehttp/promises": "^2.5", - "guzzlehttp/psr7": "^2.11", + "guzzlehttp/psr7": "^2.12", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.5 || ^3.0", @@ -2359,7 +2359,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.11.2" + "source": "https://github.com/guzzle/guzzle/tree/7.12.0" }, "funding": [ { @@ -2375,7 +2375,7 @@ "type": "tidelift" } ], - "time": "2026-06-12T21:49:57+00:00" + "time": "2026-06-16T22:11:48+00:00" }, { "name": "guzzlehttp/promises", @@ -2463,16 +2463,16 @@ }, { "name": "guzzlehttp/psr7", - "version": "2.11.1", + "version": "2.12.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "640e2897bbee822dbc8af761d49e1a29b1f2a6b1" + "reference": "9b38012e7b54f594707e6db52c684dc0a74b3a43" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/640e2897bbee822dbc8af761d49e1a29b1f2a6b1", - "reference": "640e2897bbee822dbc8af761d49e1a29b1f2a6b1", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/9b38012e7b54f594707e6db52c684dc0a74b3a43", + "reference": "9b38012e7b54f594707e6db52c684dc0a74b3a43", "shasum": "" }, "require": { @@ -2562,7 +2562,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.11.1" + "source": "https://github.com/guzzle/psr7/tree/2.12.0" }, "funding": [ { @@ -2578,7 +2578,7 @@ "type": "tidelift" } ], - "time": "2026-06-12T21:50:12+00:00" + "time": "2026-06-16T21:50:11+00:00" }, { "name": "guzzlehttp/uri-template", @@ -5042,6 +5042,75 @@ }, "time": "2020-10-15T08:29:30+00:00" }, + { + "name": "parfaitementweb/filament-country-field", + "version": "2.5.6", + "source": { + "type": "git", + "url": "https://github.com/parfaitementweb/filament-country-field.git", + "reference": "8caa6bcb4fd81bb359444399b949c6936632ee58" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/parfaitementweb/filament-country-field/zipball/8caa6bcb4fd81bb359444399b949c6936632ee58", + "reference": "8caa6bcb4fd81bb359444399b949c6936632ee58", + "shasum": "" + }, + "require": { + "filament/filament": "^3.0|^4.0|^5.0", + "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", + "php": "^8.1", + "spatie/laravel-package-tools": "^1.15.0" + }, + "require-dev": { + "larastan/larastan": "^2.8|^3.6", + "mockery/mockery": "^1.5", + "orchestra/testbench": "^7.7|^8.0|^9.0|^10.0|^11.0", + "pestphp/pest": "^1.23|^2.1|^3.1", + "phpunit/phpunit": "^9.5.24|^10.5|^11.5" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Parfaitementweb\\FilamentCountryField\\FilamentCountryFieldServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Parfaitementweb\\FilamentCountryField\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alexis", + "role": "Developer" + } + ], + "description": "Country dropdown with ISO 3166 options values", + "homepage": "https://github.com/parfaitementweb/filament-country-field", + "keywords": [ + "Parfaitementweb", + "filament-country-field", + "laravel" + ], + "support": { + "issues": "https://github.com/parfaitementweb/filament-country-field/issues", + "source": "https://github.com/parfaitementweb/filament-country-field" + }, + "funding": [ + { + "url": "https://github.com/parfaitementweb", + "type": "github" + } + ], + "time": "2026-05-21T20:12:38+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.5", @@ -10100,16 +10169,16 @@ }, { "name": "justinrainbow/json-schema", - "version": "6.9.0", + "version": "6.10.0", "source": { "type": "git", "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "bd1bda2ebfc8bff418565941771ea8f03c557886" + "reference": "8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/bd1bda2ebfc8bff418565941771ea8f03c557886", - "reference": "bd1bda2ebfc8bff418565941771ea8f03c557886", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33", + "reference": "8b1308a9d7bdbdb20ce87ef920f82b4564bb2d33", "shasum": "" }, "require": { @@ -10119,7 +10188,7 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "3.3.0", - "json-schema/json-schema-test-suite": "^23.2", + "json-schema/json-schema-test-suite": "dev-main", "marc-mabe/php-enum-phpstan": "^2.0", "phpspec/prophecy": "^1.19", "phpstan/phpstan": "^1.12", @@ -10169,9 +10238,9 @@ ], "support": { "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/6.9.0" + "source": "https://github.com/jsonrainbow/json-schema/tree/6.10.0" }, - "time": "2026-06-05T14:05:24+00:00" + "time": "2026-06-16T20:50:26+00:00" }, { "name": "larastan/larastan", diff --git a/config/services.php b/config/services.php index dabcf02c..7871c185 100644 --- a/config/services.php +++ b/config/services.php @@ -38,6 +38,7 @@ ], 'recaptcha' => [ + 'enabled' => (bool) env('RECAPTCHA_ENABLED', true), 'sitekey' => env('NOCAPTCHA_SITEKEY') ?? env('RECAPTCHA_SITEKEY'), 'secret' => env('NOCAPTCHA_SECRET') ?? env('RECAPTCHA_SECRET'), ], diff --git a/resources/views/forms/drupal-ai-demo.blade.php b/resources/views/forms/drupal-ai-demo.blade.php index a2258132..b7a1d4af 100644 --- a/resources/views/forms/drupal-ai-demo.blade.php +++ b/resources/views/forms/drupal-ai-demo.blade.php @@ -332,7 +332,9 @@ display: none; } +@if($form->getRecaptchaEnabled()) +@endif @endsection @section('content') @@ -379,24 +381,9 @@ @@ -452,10 +439,12 @@

+ @if($form->getRecaptchaEnabled())
+ @endif @@ -537,6 +526,7 @@