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')
+
+@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 @@