Compare commits

...
Author SHA1 Message Date
Jeppe Bundgaard 62f2c80dda Scope monthly invoice split endpoint 2026-07-06 13:37:31 +02:00
Jeppe BandJeppe Bundgaard 6f3d7e0f7d Add limited backoffice employee contact fields (#294)
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 13:14:06 +02:00
Jeppe B a8fba73d99 Add limited backoffice role permission details (#291)
Adds grouped safe permission metadata for limited backoffice role presets.
2026-07-06 12:24:54 +02:00
Jeppe B 669759461d Merge pull request #293 from copenhagentruckwash/codex/economic-collected-invoice-transfer-speed
Optimize collected e-conomic invoice transfers
2026-07-06 11:49:47 +02:00
Jeppe Bundgaard 38814545c4 Optimize collected e-conomic invoice transfers 2026-07-06 11:31:22 +02:00
Jeppe B 94c3654240 Merge pull request #292 from copenhagentruckwash/fix/limited-backoffice-price-save
[codex] Fix limited backoffice price saves on legacy schema
2026-07-06 11:03:44 +02:00
Jeppe Bundgaard 9fa249cc11 Fix limited backoffice price saves on legacy schema 2026-07-06 10:53:18 +02:00
Jeppe Bundgaard 215c8d0fbb Add limited backoffice role permission details 2026-07-06 10:38:51 +02:00
Jeppe BandJeppe Bundgaard 3f41eebdf6 Default vehicle subscriptions to false (#288)
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2026-07-06 09:21:31 +02:00
Jeppe B 64e0b2444b Merge pull request #289 from copenhagentruckwash/fix/limited-backoffice-api-compat
[codex] Fix limited backoffice schema compatibility
2026-07-06 09:20:56 +02:00
Jeppe Bundgaard 18fede78f8 Fix limited backoffice schema compatibility 2026-07-06 09:15:55 +02:00
Jeppe Bundgaard 243d68ab59 Fix edge gateway relay command draining 2026-07-02 12:41:34 +02:00
Jeppe Bundgaard 62c1393f62 Refactor product data handling and improve error management in superuser products layout 2026-07-02 11:48:21 +02:00
Jeppe B f0a8299133 Merge pull request #287 from copenhagentruckwash/fix/api-ci-limited-backoffice
Fix API CI failures
2026-07-02 11:03:12 +02:00
Jeppe Bundgaard e36f6da926 Run Qodana on backend runner pool 2026-07-02 10:57:47 +02:00
Jeppe Bundgaard 4a8c2a9fd9 Avoid npm cache hang in edge CI 2026-07-02 10:50:25 +02:00
Jeppe Bundgaard 248b2e4eca Fix API CI failures 2026-07-02 10:42:13 +02:00
Jeppe Bundgaard 1d1ebd2176 Add limited backoffice functionality with employee management and department pricing 2026-07-01 16:37:32 +02:00
Jeppe Bundgaard 4252f9a42b Stabilize edge gateway API CI 2026-07-01 14:01:50 +02:00
Jeppe Bundgaard 4fdeedab45 Handle invalid edge installer tokens 2026-07-01 13:41:20 +02:00
Jeppe Bundgaard 866a5be126 Refactor subuser permissions and enhance artifact management 2026-07-01 13:18:27 +02:00
Jeppe Bundgaard 11d39af934 Normalize edge broker URL updates 2026-07-01 11:53:01 +02:00
Jeppe Bundgaard f706531534 Stabilize gateway E2E broker config 2026-07-01 11:44:36 +02:00
Jeppe Bundgaard a839eac4c1 Allow HTTP operation completion in gateway E2E 2026-07-01 11:34:39 +02:00
Jeppe Bundgaard 581d28e9ce Stabilize edge gateway CI assertions 2026-07-01 11:25:01 +02:00
Jeppe Bundgaard 2ba39b8174 Fix edge gateway CI broker path 2026-07-01 11:17:01 +02:00
Jeppe Bundgaard 6af55a44c9 Fix API CI broker and transport fixtures 2026-07-01 11:09:38 +02:00
Jeppe Bundgaard 24badc39d7 Keep broker path in API CI public URL 2026-07-01 11:02:26 +02:00
Jeppe Bundgaard f5e0baaab6 Use explicit Docker subnets in API CI 2026-07-01 11:00:13 +02:00
Jeppe Bundgaard 178c84ba60 Use direct broker port for API CI smoke 2026-07-01 10:57:39 +02:00
Jeppe Bundgaard 713d40a876 Prune stale Docker networks in API CI 2026-07-01 10:53:25 +02:00
Jeppe Bundgaard 9db1964038 Fix edge agent CI setup 2026-07-01 10:51:14 +02:00
Jeppe Bundgaard 1ca42055b0 Use docker-capable API runners 2026-07-01 10:42:30 +02:00
Jeppe Bundgaard c04bda7368 Normalize API runner Docker access 2026-07-01 10:38:49 +02:00
Jeppe Bundgaard 1f47843699 Fix edge expected state CI coverage 2026-07-01 10:33:56 +02:00
Jeppe Bundgaard dca738db82 Resolve edge schema DB config from env 2026-07-01 10:28:40 +02:00
Jeppe Bundgaard 57f364ad0f Use backend runners for API tests 2026-07-01 10:21:08 +02:00
Jeppe Bundgaard a826153bb5 Use namespaced DB helper in schema bootstrap 2026-07-01 10:04:28 +02:00
Jeppe Bundgaard 72bd22a707 Load DB helper for edge gateway schema bootstrap 2026-07-01 09:57:34 +02:00
Jeppe Bundgaard cc73d80dbc Use PDO in edge gateway schema bootstrap 2026-07-01 09:46:35 +02:00
Jeppe Bundgaard f0a5b15442 Add edge agent expected relay state API 2026-07-01 09:40:54 +02:00
Jeppe Bundgaard eefe5630f4 Honor staged edge gateway update windows 2026-06-30 17:23:04 +02:00
Jeppe Bundgaard b0ea771e6a Add handling for self-serve machine signals in edge agent 2026-06-30 16:19:43 +02:00
Jeppe Bundgaard eb21405a3d Add outbox replay handling with configurable limits and timeouts 2026-06-30 16:16:08 +02:00
Jeppe Bundgaard 8ea10ef808 Implement relay toggle handling with configurable timers and device generation resolution 2026-06-30 15:53:29 +02:00
Jeppe Bundgaard ac7da807bd Route local lane gate opens through edge bindings 2026-06-30 13:52:12 +02:00
Jeppe Bundgaard 3eafc597c6 Use edge bindings for lane hardware batch relays 2026-06-30 13:28:03 +02:00
Jeppe Bundgaard f7a4126718 Fix router matching for underscored route params 2026-06-30 13:10:06 +02:00
Jeppe Bundgaard c6cf953ede Fix relay batch gateway binding resolution 2026-06-30 12:52:53 +02:00
Jeppe Bundgaard d902202fe9 Fallback relay commands after broker dispatch failures 2026-06-30 12:32:10 +02:00
Jeppe Bundgaard acc80920f9 Preserve LAN worker auth after agent config reload 2026-06-30 11:39:19 +02:00
Jeppe Bundgaard 53246af629 Update EdgeGatewayManagerUrlTest to improve installation phase checks and remove obsolete heartbeat expectations 2026-06-30 11:18:00 +02:00
Jeppe Bundgaard bd87e94472 Normalize line endings in install scripts and enhance service stop commands for robustness 2026-06-30 11:06:33 +02:00
Jeppe Bundgaard 7ccbb68ffa Refactor Dockerfiles to streamline PHP extension checks and remove unnecessary installations 2026-06-30 10:58:08 +02:00
Jeppe Bundgaard 4a7fc7c534 Refactor Dockerfiles to conditionally install curl extension if not already present 2026-06-30 10:35:30 +02:00
Jeppe Bundgaard f4343ae114 Enhance stopping wash functionality with local storage management and restoration logic 2026-06-30 10:16:13 +02:00
Jeppe Bundgaard 0da02dfeb5 Add batch processing for lane hardware commands and status retrieval 2026-06-30 09:30:43 +02:00
Jeppe Bundgaard 5f13242cfa Fix eligibility check by ensuring user is not null in lane access condition 2026-06-29 15:35:26 +02:00
Jeppe Bundgaard b492292642 Add machine wash configuration and update self-serve lane service checks 2026-06-29 15:22:22 +02:00
Jeppe Bundgaard ce8ba88b16 Add machine wash configuration and controls to self-serve module 2026-06-29 15:15:29 +02:00
Jeppe Bundgaard 02b6df5e3b Refactor authentication handling and permission checks in self-serve routes 2026-06-29 14:53:58 +02:00
Jeppe Bundgaard 6cc4f2759d Enhance self-serve functionality by adding lane availability checks and updating response data 2026-06-29 14:25:51 +02:00
Jeppe Bundgaard 3ea92be722 Add endpoint and functionality to test Slack internal department goal progress webhook 2026-06-29 11:32:47 +02:00
Jeppe Bundgaard 42f8ae0c47 Refactor input value handling and improve validation in ObjectsGlobal component 2026-06-29 11:08:50 +02:00
Jeppe Bundgaard 148b575767 Refactor input value handling and improve validation in ObjectsGlobal component 2026-06-29 08:28:00 +02:00
Jeppe B 605efacece Merge pull request #284 from copenhagentruckwash/codex/optimize-scanner-lpr-backend
[codex] optimize scanner LPR backend
2026-06-12 22:08:35 +02:00
Jeppe Bundgaard 1ccd7749d0 optimize scanner lpr backend 2026-06-12 21:42:36 +02:00
Jeppe B b3ba3c8de5 Merge pull request #283 from copenhagentruckwash/fix/pwa-selfserve-stop-latency
[codex] Reduce self-serve latency and add PHP-FPM workers
2026-06-12 13:15:28 +02:00
Jeppe Bundgaard 4a65b669bd Fix CI FPM worker config test 2026-06-12 12:35:06 +02:00
Jeppe Bundgaard aaec443140 Configure multiple PHP-FPM workers 2026-06-12 12:27:27 +02:00
Jeppe Bundgaard 3cdf1571c5 Avoid duplicate self-serve stop relay cleanup 2026-06-12 11:48:51 +02:00
Jeppe Bundgaard 36b934e835 Increase password reset token validity to 72 hours and update related email message 2026-06-11 21:45:22 +02:00
Jeppe B 5027d0c919 Merge pull request #282 from copenhagentruckwash/codex/register-cvr-welcome-email-fix
[codex] Fix register CVR welcome email rendering
2026-06-11 21:22:28 +02:00
Jeppe Bundgaard f0baadd59f Register welcome email legacy test 2026-06-11 21:08:28 +02:00
Jeppe Bundgaard 19cacebaa1 Fix register CVR welcome email rendering 2026-06-11 21:01:34 +02:00
Jeppe Bundgaard 4d9d61455f Refactor company phone number registration error handling and enhance CVR lookup test cases 2026-06-11 20:25:16 +02:00
Jeppe Bundgaard fc87b3a8aa Improve CVR lookup error handling and unify phone number registration error messages 2026-06-11 20:17:48 +02:00
Jeppe Bundgaard 8e0936001d Fix company phone number registration error messages for clarity 2026-06-11 20:03:55 +02:00
Jeppe B af8968a87e Merge pull request #281 from copenhagentruckwash/codex/customer-registration-notifications
Add Slack customer registration webhook test endpoint
2026-06-11 15:18:07 +02:00
Jeppe Bundgaard e6a18ce5d8 Add Slack customer registration webhook test endpoint 2026-06-11 15:06:31 +02:00
Jeppe B b5c24ef80a Merge pull request #280 from copenhagentruckwash/fix/self-serve-path-outcome-case-limit
Fix self-serve path outcome case limit
2026-06-11 14:57:16 +02:00
Jeppe Bundgaard df7153a5ba Fix self-serve path outcome case limit 2026-06-11 14:46:27 +02:00
Jeppe Bundgaard d06c78119b Fix customer registration duplicate recovery 2026-06-11 12:04:56 +02:00
Jeppe B bdb1a0074b Merge pull request #279 from copenhagentruckwash/fix/self-serve-customer-property-gates
Allow customers to open property gates for active washes
2026-06-10 22:00:06 +02:00
Jeppe Bundgaard c0de0e9d6b Allow customers to open property gates for active washes 2026-06-10 21:00:18 +02:00
Jeppe B 574b263a54 Merge pull request #278 from copenhagentruckwash/fix/self-serve-start-wash-type
Honor wash type in self-serve lane start
2026-06-10 20:07:46 +02:00
Jeppe B 36ff5bb438 Merge pull request #277 from copenhagentruckwash/fix-completion-confirmation-route
Add order booking completion confirmation resend route
2026-06-10 20:07:30 +02:00
Jeppe Bundgaard 1beca924fc Fallback composer installs to source in CI 2026-06-10 19:47:17 +02:00
Jeppe B d605eca574 Fallback composer installs to source in CI 2026-06-10 19:22:52 +02:00
Jeppe Bundgaard cea469c95a Honor wash type in self-serve lane start 2026-06-10 19:18:19 +02:00
Jeppe B 7e85c74e60 Retry composer installs in CI 2026-06-10 19:12:12 +02:00
Jeppe B 67d62eff70 Sync fake email deliveries across API tests 2026-06-10 18:53:13 +02:00
Jeppe B 8ebbd52a99 Normalize attachment object type lookups 2026-06-10 18:40:34 +02:00
Jeppe B 6d6cc501db Force completion confirmation resend email 2026-06-10 18:33:53 +02:00
Jeppe B ce999afbb3 Fix MinIO local test storage fallback 2026-06-10 18:22:53 +02:00
Jeppe B cb34b030c8 Add order booking completion confirmation resend route 2026-06-10 17:55:57 +02:00
Jeppe Bundgaard e26034dfae Refactor dynamic image export methods to use binary output and improve caching logic 2026-06-09 14:48:46 +02:00
Jeppe Bundgaard 0aad41fd0f Add program picker relay status handling for wash start and update related tests 2026-06-09 14:09:49 +02:00
Jeppe Bundgaard 5c67fe419f Add timeout settings for Shelly cloud HTTP requests and update related tests 2026-06-09 14:03:12 +02:00
Jeppe Bundgaard aca8be51dc Implement move collected invoice to customer functionality with API endpoint and associated tests 2026-06-09 13:12:55 +02:00
Jeppe Bundgaard fb1f0883e1 Add selfserve dynamic image sizing config 2026-06-09 12:46:14 +02:00
Jeppe Bundgaard 6446eb2e36 Test manual wash program picker selection 2026-06-09 12:05:27 +02:00
Jeppe Bundgaard a1224ec2f4 Fix self-serve program picker wash type sync 2026-06-09 11:59:42 +02:00
Jeppe Bundgaard 07441c4ed1 Harden API auto deploy gate 2026-06-08 18:44:25 +02:00
Jeppe Bundgaard cd100f1180 Invalidate cached session payloads on notification preferences update; enhance flag tab filtering logic 2026-06-08 18:40:15 +02:00
Jeppe Bundgaard 4fc66c72b8 Prefer selected Coolify deployment commit 2026-06-08 18:14:45 +02:00
Jeppe Bundgaard a3ea5fee83 Clean up stale Coolify API routes 2026-06-08 18:02:08 +02:00
Jeppe Bundgaard ef8d97c821 Verify API release commit in gateway gate 2026-06-08 17:48:01 +02:00
Jeppe Bundgaard 327a77edf4 Require API gateway release check 2026-06-08 17:07:49 +02:00
Jeppe Bundgaard d8abc8f87d Refactor session relay synchronization logic for improved clarity 2026-06-08 16:59:53 +02:00
Jeppe Bundgaard 325b35beb7 Add session synchronization tests and ensure atomic session closure 2026-06-08 16:53:24 +02:00
Jeppe Bundgaard 49364864d2 Implement session mutation locking and enhance session management methods 2026-06-08 16:49:41 +02:00
Jeppe Bundgaard a19178a042 Add PHPStan and Rector configuration files for static analysis and code quality 2026-06-08 16:33:37 +02:00
Jeppe Bundgaard 91d3332d4e Add Slack customer registration notification functionality 2026-06-08 12:42:03 +02:00
Jeppe Bundgaard bedbf21c29 Add superuser new customer email notification preferences 2026-06-08 12:20:07 +02:00
Jeppe Bundgaard 75c19bcce4 Fix MyWash active summary refresh import 2026-06-04 08:31:51 +02:00
Jeppe Bundgaard 458fe7399d Persist MyWash sessions on start command 2026-06-04 08:18:31 +02:00
Jeppe Bundgaard 2b6a8eedcc Avoid session creation on MyWash summary refresh 2026-06-04 08:07:40 +02:00
Jeppe Bundgaard c0ed107f75 Resolve MyWash services from published config 2026-06-04 07:50:14 +02:00
Jeppe Bundgaard 30dceff0b5 Avoid self-serve preview sessions on eligibility reads 2026-06-04 06:55:59 +02:00
Jeppe Bundgaard 716929bd7b Inject frontend commit SHA into Coolify runtime environment for manifest builds 2026-06-04 00:38:31 +02:00
Jeppe Bundgaard 3d221f3379 Merge remote-tracking branch 'origin/master' 2026-06-03 21:03:27 +02:00
Jeppe B 6f1c160fbb Sync generated Copilot workflow 2026-06-03 20:57:06 +02:00
Jeppe Bundgaard 9694695f00 Sync generated Copilot workflow 2026-06-03 20:56:06 +02:00
Jeppe Bundgaard 1d43221b4d Sync self-serve machine relay session state 2026-06-03 20:35:50 +02:00
Jeppe Bundgaard 33b7c3e51a Widen self-serve task descriptions 2026-06-03 19:06:09 +02:00
Jeppe Bundgaard 1e64bd63b8 Update PHPUnit test results cache with latest version and defect counts 2026-06-03 18:19:45 +02:00
Jeppe B bcbc2481c3 Source self-serve lane products from published config 2026-06-02 19:05:06 +02:00
Jeppe B 8288a1069c Merge pull request #276
coolify-github-runner-management
2026-06-02 17:27:42 +02:00
Jeppe Bundgaard 1b99523366 Enhance self-serve lane functionality with new relay management and configuration updates 2026-06-02 17:27:22 +02:00
Jeppe Bundgaard 6d739cfebc Add configuration for GitHub self-hosted runners 2026-06-02 11:25:00 +02:00
Jeppe Bundgaard 20071166f8 Switch CI to self-hosted runners
Updated all GitHub Actions workflows to use self-hosted runners instead of `ubuntu-latest`. This change ensures better control over the CI environment and aligns with internal infrastructure requirements.
2026-06-02 10:36:22 +02:00
Jeppe Bundgaard c23168afc5 Merge remote-tracking branch 'origin/master' 2026-06-02 10:29:25 +02:00
Jeppe Bundgaard 72704b7806 Add "Get My Active Self-Serve Wash" endpoint and corresponding tests
- Introduced a new `/modules/self-serve/lane/wash/my-active-wash` endpoint to retrieve the authenticated customer's active self-serve wash.
- Implemented authentication and permission checks for secure access.
- Added detailed response handling for various scenarios, including 401, 403, and 404 statuses.
- Extended API documentation and OpenAPI spec to support the new endpoint.
- Updated unit and API tests to validate endpoint functionality and route wiring.
2026-06-02 10:29:15 +02:00
Jeppe B f7485f0767 Merge pull request #273 from copenhagentruckwash/update-self-serve-lane-command-access-logic
Allow customer self-serve lane commands
2026-06-02 10:22:21 +02:00
copilot-swe-agent[bot] 975909b6a1 Resolve merge conflict with master in SelfserveLaneCommandApiTest.php 2026-06-02 08:15:18 +00:00
Jeppe B 1468e43ce2 Merge pull request #272 from copenhagentruckwash/add-endpoint-to-resend-booking-confirmations
Add booking confirmation resend endpoint
2026-06-02 10:12:29 +02:00
Jeppe B ee2af5091c Retry CI docker compose startup 2026-06-02 10:08:01 +02:00
Jeppe B 0672a68e8b Merge pull request #274 from copenhagentruckwash/update-self-serve-lane-command-access-logic-bft43z
Support customer self-serve lane commands with operational/department checks and tests
2026-06-02 10:07:27 +02:00
copilot-swe-agent[bot] c8804bc8dc Merge master into branch resolving self-serve lane command conflicts 2026-06-02 08:01:46 +00:00
Jeppe B b92d1f0bdf Fix self-serve lane command API tests 2026-06-02 09:52:44 +02:00
copilot-swe-agent[bot] cc10371346 Resolve merge conflict with master in moduleSelfServeRoute.php 2026-06-02 07:40:01 +00:00
Jeppe B ac60596218 Fix booking confirmation resend test fixture 2026-06-02 09:36:36 +02:00
Jeppe B f4b9d71d40 Merge pull request #270 from copenhagentruckwash/add-customer-self-serve-module-authorization-checks
Guard customer self-serve command fallback behind global module flag
2026-06-02 09:25:00 +02:00
Jeppe B 77b1c8ec78 Merge pull request #271 from copenhagentruckwash/inspect-command-authorization-for-self-serve-route
Authorize self-serve lane commands by customer scope and operator permission
2026-06-02 09:24:38 +02:00
Jeppe B 01221d8282 Allow customer self-serve lane commands 2026-06-02 09:24:33 +02:00
Jeppe B 47068e6d7e Add booking confirmation resend endpoint 2026-06-02 09:15:34 +02:00
Jeppe B 46bdeded78 Fix self-serve lane command customer authorization 2026-06-02 09:15:24 +02:00
Jeppe B eefa521fc5 Guard customer self-serve commands behind module flag 2026-06-02 09:14:57 +02:00
Jeppe B ec1988715d Merge pull request #269 from copenhagentruckwash/fix-parse-error-in-index.php
Handle Release Manager gate parse-error deadlock
2026-06-02 02:53:54 +02:00
Jeppe B c3fb2e8651 Handle release gate parse-error deadlock 2026-06-02 02:50:02 +02:00
Jeppe B 0fb279fc5f Merge pull request #268 from copenhagentruckwash/investigate-and-fix-failing-tests
Resolve PHP merge conflicts and restore search/autoload behavior
2026-06-02 02:33:35 +02:00
Jeppe B 3e970d9cb9 Seed subuser session cache in API fixtures 2026-06-02 02:29:52 +02:00
Jeppe B 8c10c07cc9 Resolve Caddy replication bootstrap conflict 2026-06-02 02:25:29 +02:00
Jeppe B 18a8513b40 Use namespaced subuser object in API fixtures 2026-06-02 02:21:53 +02:00
Jeppe B 4c77b78c6c Keep self-serve invoice billing customer authoritative 2026-06-02 02:15:27 +02:00
Jeppe B bb249da477 Align API tests with hardened auth and department access 2026-06-02 02:09:14 +02:00
Jeppe B eb16a4e6ce Fix collected invoice queue count expectations 2026-06-02 02:00:35 +02:00
Jeppe B a2e525fa9e Update unit expectations for hardened flows 2026-06-02 01:53:58 +02:00
Jeppe B 0bf19c9d33 Restrict indexed department filters to scoped entities 2026-06-02 01:34:21 +02:00
Jeppe B 5850bfbce7 Keep autoload cache validation test compatible 2026-06-02 01:16:20 +02:00
Jeppe B fd51a5b119 Fix search table argument ordering 2026-06-02 01:07:45 +02:00
Jeppe B 140365c8bb Resolve PHP merge conflict test failures 2026-06-02 00:58:15 +02:00
Jeppe B c9ceac8533 Merge pull request #267 from copenhagentruckwash/fix-permission-checks-for-subuser-endpoints
Require SUBUSERS_LIST permission for GET /subusers to enforce RBAC
2026-06-02 00:42:48 +02:00
copilot-swe-agent[bot] 4266b933f5 Merge remote-tracking branch 'origin/master' into fix-permission-checks-for-subuser-endpoints
# Conflicts:
#	services/nginx/app/routes/subusersRoute.php
2026-06-01 22:41:35 +00:00
Jeppe B 72ec62d042 Merge pull request #259 from copenhagentruckwash/fix-redis-autoload-cache-vulnerability
Harden Redis-backed autoloader against poisoned path inclusion
2026-06-02 00:37:33 +02:00
copilot-swe-agent[bot] d24b50f751 Plan: Resolve merge conflicts in index.php autoloader 2026-06-01 22:36:09 +00:00
Jeppe B 21f5e6d9cf Enforce permission check on subuser list endpoint 2026-06-02 00:35:48 +02:00
Jeppe B 73b91ccec9 Merge pull request #265 from copenhagentruckwash/fix-unauthenticated-bird-voice-webhook
Reinstate authorization check for Bird inbound voice webhook
2026-06-02 00:33:43 +02:00
Jeppe B 0c809a19da Merge pull request #257 from copenhagentruckwash/propose-fix-for-redis-image-cache-vulnerability
Limit Redis dynamic image caching to default variant only
2026-06-02 00:33:27 +02:00
Jeppe B 3a6685c345 Merge pull request #255 from copenhagentruckwash/fix-system-search-authorization-bypass
Enforce department scoping in system search for generic entities
2026-06-02 00:33:02 +02:00
Jeppe B a60983f328 Merge pull request #266 from copenhagentruckwash/propose-fix-for-n8n-ssrf-vulnerability
Harden n8n webhook trigger URL validation against SSRF
2026-06-02 00:32:47 +02:00
Jeppe B e2c2eb21cb Harden n8n webhook trigger URL validation 2026-06-02 00:32:35 +02:00
copilot-swe-agent[bot] 51c619b0c6 Resolve merge conflicts in departmentLanesRoute.php 2026-06-01 22:29:49 +00:00
copilot-swe-agent[bot] fe9daf1bf2 Merge remote-tracking branch 'origin/master' into fix-unauthenticated-bird-voice-webhook
# Conflicts:
#	services/nginx/app/routes/birdVoiceWebhooksRoute.php
2026-06-01 22:27:40 +00:00
copilot-swe-agent[bot] 9c2d7140b4 Merge master into branch to resolve conflicts 2026-06-01 22:27:04 +00:00
copilot-swe-agent[bot] 1505464095 Plan: Resolve merge conflicts with master 2026-06-01 22:25:49 +00:00
Jeppe B cc00fb2aed Reinstate auth on Bird inbound voice webhook 2026-06-02 00:23:58 +02:00
Jeppe B 7f38cf2f7e Merge pull request #264 from copenhagentruckwash/propose-fix-for-ssrf-in-workfeed-api
Restrict Workfeed API base URL to trusted hosts (prevent SSRF)
2026-06-02 00:22:35 +02:00
Jeppe B d281dddbc1 Restrict Workfeed API base URL 2026-06-02 00:22:18 +02:00
Jeppe B 267ec1bed1 Merge pull request #263 from copenhagentruckwash/fix-machine-relay-set-endpoint-vulnerability
Guard machine relay set status
2026-06-02 00:21:49 +02:00
Jeppe B a96f40cf13 Guard machine relay set status 2026-06-02 00:21:32 +02:00
Jeppe B d6190626ce Merge pull request #262 from copenhagentruckwash/fix-cross-tenant-job-data-exposure
Scope economic transfer queue jobs by creator
2026-06-02 00:20:48 +02:00
Jeppe B ce8e6d0dab Scope economic transfer queue jobs by creator 2026-06-02 00:20:30 +02:00
Jeppe B c13c2e2cab Merge pull request #261 from copenhagentruckwash/fix-customer-data-leak-in-wash-endpoint
Restrict in-progress wash details by lane department
2026-06-02 00:20:07 +02:00
Jeppe B 7380bc729b Restrict in-progress wash details by lane department 2026-06-02 00:19:55 +02:00
Jeppe B 434a5049e2 Merge pull request #260 from copenhagentruckwash/fix-unpinned-github-actions-vulnerability
Harden Qodana workflow permissions and pin checkout action
2026-06-02 00:17:44 +02:00
copilot-swe-agent[bot] 6489706231 Merge master and resolve conflicts
- Retained security improvements from master (token detection, cache prep, safe directory)
- Applied security hardening by pinning actions/checkout@v4 to commit SHA 11bd71901bbe5b1630ceea73d27597364c9af683
- Added persist-credentials: false to checkout step to prevent credential exposure
2026-06-01 22:14:21 +00:00
Jeppe B eb66b343ea Harden Qodana workflow permissions and checkout pin 2026-06-02 00:04:10 +02:00
Jeppe B e363f27da9 Harden autoload Redis cache path validation 2026-06-02 00:02:40 +02:00
Jeppe B fbad5f767f Merge pull request #258 from copenhagentruckwash/fix-hardcoded-auth-tokens-in-configuration
Sanitize leaked auth tokens in HTTP test env
2026-06-02 00:02:06 +02:00
Jeppe B 94d9b347bf Sanitize leaked auth tokens in HTTP test env 2026-06-02 00:01:57 +02:00
Jeppe B 76744fd6c3 Limit dynamic image Redis caching to default variant 2026-06-02 00:00:04 +02:00
Jeppe B f5c1a34c29 Merge pull request #256 from copenhagentruckwash/fix-idor-vulnerability-in-economic-v2-endpoints
Prevent IDOR on Economic V2 collected-invoice endpoints
2026-06-01 23:58:41 +02:00
Jeppe B 22ad96bc8e Fix economic v2 invoice endpoint authorization scope 2026-06-01 23:58:30 +02:00
Jeppe B 8a749cffa3 Fix system search department scoping for generic entities 2026-06-01 23:58:03 +02:00
Jeppe B cf5cf8d5eb Merge pull request #254 from copenhagentruckwash/fix-user-search-exposure-vulnerability
Restrict `users` system-search access to prevent PII leakage
2026-06-01 23:57:33 +02:00
Jeppe B eb14b7039b Restrict users system search permissions 2026-06-01 23:57:23 +02:00
Jeppe B f09b1263c1 Merge pull request #253 from copenhagentruckwash/fix-stripe-payment-intent-reuse-issue
Validate Stripe payment intent amount before reuse
2026-06-01 23:54:32 +02:00
Jeppe B 9ec8499d55 Validate Stripe payment intent amount before reuse 2026-06-01 23:54:04 +02:00
Jeppe B 8d40cd6f9a Merge pull request #252 from copenhagentruckwash/fix-complaint-endpoints-department-access-check
Require department access for department daily report complaint routes
2026-06-01 23:53:45 +02:00
Jeppe B ccffad3c7c Fix complaint department authorization 2026-06-01 23:53:36 +02:00
Jeppe B 4697c6b272 Merge pull request #251 from copenhagentruckwash/fix-subuser-management-permission-checks
Enforce own-scope subuser permissions for classic users in managed customer scope
2026-06-01 23:53:06 +02:00
Jeppe B 45e17e196c Fix subuser management permission scope 2026-06-01 23:52:57 +02:00
Jeppe B dcc81cbdc7 Merge pull request #250 from copenhagentruckwash/propose-fix-for-privilege-boundary-regression
Restrict studio simulation to config-version view and prevent auto-creating drafts
2026-06-01 23:52:28 +02:00
Jeppe B c5cb0a3bfe Fix studio simulation draft access 2026-06-01 23:52:18 +02:00
Jeppe B 465f3ed027 Merge pull request #249 from copenhagentruckwash/fix-edge-agent-vulnerability-for-unsigned-artifacts
Require checksums for edge agent updates
2026-06-01 23:50:05 +02:00
Jeppe B 80ff01f04e Require checksums for edge agent updates 2026-06-01 23:49:56 +02:00
Jeppe B f6e4d851d3 Merge pull request #248 from copenhagentruckwash/fix-authenticated-ssrf-in-broker-diagnostics
Prevent SSRF in broker diagnostics by ignoring caller URLs and redacting probe output
2026-06-01 23:49:24 +02:00
Jeppe B cd4e3faea3 Fix broker diagnostics SSRF 2026-06-01 23:49:10 +02:00
Jeppe B 4cb9e68b33 Merge pull request #247 from copenhagentruckwash/fix-information-disclosure-in-websocket-upgrades
Sanitize websocket upgrade error responses
2026-06-01 23:47:09 +02:00
Jeppe B 0e7e79d205 Sanitize websocket upgrade errors 2026-06-01 23:46:59 +02:00
Jeppe B a9ca7b41a7 Merge pull request #246 from copenhagentruckwash/fix-unbounded-relay-timer-vulnerability
Cap self-serve gate relay timers
2026-06-01 23:45:39 +02:00
Jeppe B 3b3ed31bb7 Cap self-serve gate relay timers 2026-06-01 23:45:20 +02:00
Jeppe B cf9d5875ef Merge pull request #242 from copenhagentruckwash/fix-vulnerability-with-self-hosted-runners
Run PR code quality workflow on GitHub-hosted runner
2026-06-01 23:44:29 +02:00
Jeppe B 4730eebdb4 Merge pull request #245 from copenhagentruckwash/fix-internal-ip-address-leakage
Stop exposing relay local IPs by default
2026-06-01 23:44:05 +02:00
Jeppe B b25ce9cb11 Stop exposing relay local IPs by default 2026-06-01 23:43:53 +02:00
Jeppe B fdb98f1399 Merge pull request #244 from copenhagentruckwash/fix-unauthenticated-relay-control-vulnerability
Require authorization for LAN worker relay endpoints
2026-06-01 23:43:34 +02:00
Jeppe B 1dc758a3a3 Require authorization for LAN worker relay endpoints 2026-06-01 23:43:23 +02:00
Jeppe B 032ce93d5e Merge pull request #243 from copenhagentruckwash/fix-hardcoded-service-credentials-in-docker-config
Secure edge gateway service credentials
2026-06-01 23:42:47 +02:00
Jeppe B f8ced3b8f2 Secure edge gateway service credentials 2026-06-01 23:42:34 +02:00
copilot-swe-agent[bot] a81e239de8 Merge master into branch and resolve code_quality.yml comment conflict 2026-06-01 21:42:21 +00:00
Jeppe B 9ea5a62577 Merge pull request #238 from copenhagentruckwash/fix-cache-only-lookup-for-invoice-flags
Normalize invoice-period cache keys and restore DB fallbacks for missing Redis entries
2026-06-01 23:41:26 +02:00
Jeppe B af89a246db Run PR code quality workflow on GitHub-hosted runner 2026-06-01 23:38:32 +02:00
Jeppe B 20c1973565 Merge pull request #241 from copenhagentruckwash/fix-telemetry-path-error-message-leak
Sanitize telemetry ingestion errors
2026-06-01 23:37:59 +02:00
copilot-swe-agent[bot] 3555904423 Merge origin/master and resolve invoice_period_flag_service conflict 2026-06-01 21:37:53 +00:00
Jeppe B a2dda5ea5b Sanitize telemetry ingestion errors 2026-06-01 23:37:48 +02:00
Jeppe B ce29cf9ccb Merge pull request #240 from copenhagentruckwash/propose-fix-for-ci-vulnerability
Secure Qodana pull request workflow
2026-06-01 23:36:53 +02:00
Jeppe B e7481297c8 Secure Qodana PR workflow runner 2026-06-01 23:36:44 +02:00
Jeppe B e3b38519fb Merge pull request #239 from copenhagentruckwash/propose-fix-for-qodana-vulnerability
Skip Qodana when cloud token is missing
2026-06-01 23:31:34 +02:00
Jeppe B d244c000c3 Skip Qodana when cloud token is missing 2026-06-01 23:31:24 +02:00
Jeppe B dcd57c7092 Merge pull request #221 from copenhagentruckwash/fix-system-search-associations-vulnerability
Prevent association expansion from bypassing own-only access
2026-06-01 23:31:00 +02:00
Jeppe B 0a7e58fc01 Merge pull request #222 from copenhagentruckwash/fix-hardcoded-bearer-tokens-in-tests
Remove hardcoded API credentials and resolve merge conflict in test HTTP file
2026-06-01 23:29:28 +02:00
Jeppe B bd7deaeded Fix invoice period flag cache fallbacks 2026-06-01 23:29:05 +02:00
Jeppe B 07a3ef6418 Merge pull request #237 from copenhagentruckwash/fix-concurrent-access-vulnerability-in-start-command
Add per-lane START lock to prevent TOCTOU relay replay on wash start
2026-06-01 23:28:45 +02:00
Jeppe B bffed6f5f3 Fix self-serve start relay race 2026-06-01 23:28:36 +02:00
Jeppe B bfec31f94b Merge pull request #236 from copenhagentruckwash/fix-task-attachment-link-vulnerability
Enforce lane department authorization for self-serve eligibility
2026-06-01 23:28:05 +02:00
Jeppe B 2123835aae Fix self-serve eligibility lane authorization 2026-06-01 23:27:56 +02:00
Jeppe B 11f06e8f53 Merge pull request #235 from copenhagentruckwash/investigate-self-serve-path-projection-dos-vulnerability
Clamp self-serve path projection limits
2026-06-01 23:27:33 +02:00
Jeppe B 6712368323 Clamp self-serve path projection limits 2026-06-01 23:27:22 +02:00
Jeppe B 99fe659dbc Merge pull request #234 from copenhagentruckwash/propose-fix-for-archived-department-vulnerability
Fix department archived filter smuggling
2026-06-01 23:27:06 +02:00
Jeppe B 357cfda46e Fix department archived filter smuggling 2026-06-01 23:26:57 +02:00
Jeppe B 9c85135a07 Merge pull request #233 from copenhagentruckwash/fix-cross-tenant-vehicle-reference-leak
Restrict vehicle reference suggestions by department context
2026-06-01 23:26:36 +02:00
Jeppe B a8a47104dd Restrict vehicle reference suggestions by department context 2026-06-01 23:26:27 +02:00
Jeppe B 2c0907c486 Merge pull request #232 from copenhagentruckwash/fix-vulnerability-in-automatic-invoice-flags
Fix automatic invoice period flag suppression
2026-06-01 23:26:02 +02:00
copilot-swe-agent[bot] b1647b4ad1 Merge origin/master and resolve orderBookingsPost conflict 2026-06-01 21:25:58 +00:00
Jeppe B 0ae28af309 Fix invoice period automatic flag cache misses 2026-06-01 23:25:54 +02:00
copilot-swe-agent[bot] 64d7e6f061 Merge master into fix-system-search-associations-vulnerability 2026-06-01 21:25:51 +00:00
Jeppe B 4f9a10402b Merge pull request #231 from copenhagentruckwash/fix-exposure-of-private-git-commit-metadata
Redact GitHub commit metadata from public release runtime
2026-06-01 23:25:38 +02:00
Jeppe B 5e8ec85943 Redact release GitHub metadata from public runtime 2026-06-01 23:25:28 +02:00
Jeppe B 20d6056e40 Merge pull request #230 from copenhagentruckwash/fix-permission-bypass-for-invoice-flags
Guard invoice period flags by list permission
2026-06-01 23:25:02 +02:00
Jeppe B 5be6bc0198 Guard invoice period flags by list permission 2026-06-01 23:24:50 +02:00
Jeppe B 373aa7effb Merge pull request #227 from copenhagentruckwash/fix-minio-credentials-exposure-vulnerability
Deny web access to replication bootstrap snapshots
2026-06-01 23:24:26 +02:00
Jeppe B 5282ee10ba Merge pull request #229 from copenhagentruckwash/propose-fix-for-booking-po-vulnerability
Validate booking ownership before defaulting order PO (prevent cross-tenant leak)
2026-06-01 23:24:00 +02:00
copilot-swe-agent[bot] 06cba73a30 Initialize merge conflict resolution plan 2026-06-01 21:23:50 +00:00
Jeppe B eab8394579 Fix booking PO default tenant validation 2026-06-01 23:23:49 +02:00
Jeppe B b88c2742e8 Merge pull request #228 from copenhagentruckwash/fix-partial-release-tests-bypassing-promotion-gate
Require app-scoped release gates for bundle promotion
2026-06-01 23:23:34 +02:00
Jeppe B 4ea5eeb942 Require app-scoped release gates for bundle promotion 2026-06-01 23:23:23 +02:00
Jeppe B e41b226529 Deny web access to replication bootstrap snapshots 2026-06-01 23:19:04 +02:00
Jeppe B 7cb248a112 Merge pull request #226 from copenhagentruckwash/fix-ssrf-vulnerability-in-release-gate
Harden release gate diagnostics fetches
2026-06-01 23:17:01 +02:00
Jeppe B dfa0441266 Harden release gate diagnostics fetches 2026-06-01 23:16:51 +02:00
Jeppe B d9dbd7dede Merge pull request #225 from copenhagentruckwash/propose-fix-for-coolify-deployment-vulnerability
Prevent Coolify image from embedding replication snapshots
2026-06-01 23:16:33 +02:00
Jeppe B 3b8463e37f Prevent Coolify image from embedding replication snapshots 2026-06-01 23:16:22 +02:00
Jeppe B 0e8b527ee4 Merge pull request #224 from copenhagentruckwash/fix-qodana-scan-fail-open-issue
Run Qodana locally when cloud token is missing
2026-06-01 23:15:47 +02:00
Jeppe B 86fb8bb700 Run Qodana without upload when token is missing 2026-06-01 23:15:36 +02:00
Jeppe B 6fbf7f271d Merge pull request #223 from copenhagentruckwash/propose-fix-for-exposed-bootstrap-secret
Protect replication bootstrap file from static serving
2026-06-01 23:14:52 +02:00
Jeppe B fe5ebdc203 Protect replication bootstrap file from static serving 2026-06-01 23:14:43 +02:00
Jeppe B c6dbc0728f Remove hardcoded credentials from orderBookingsPost HTTP examples 2026-06-01 23:14:12 +02:00
Jeppe B a0b1dcb3e3 Fix system search association expansion for own-only types 2026-06-01 23:13:50 +02:00
Jeppe B 38c4c32f07 Merge pull request #220 from copenhagentruckwash/fix-empty-edge-broker-secret-vulnerability
Fail closed when edge broker secret is missing
2026-06-01 23:13:13 +02:00
Jeppe B b6beb9622b Fail closed when edge broker secret is missing 2026-06-01 23:13:04 +02:00
Jeppe B db80dad15f Merge pull request #219 from copenhagentruckwash/propose-fix-for-unauthenticated-pdf-access
Fix unauthenticated PDF disclosure in file_server fallback
2026-06-01 23:12:00 +02:00
Jeppe B cf370a8035 Fix unauthenticated pdf_store access in file server 2026-06-01 23:11:48 +02:00
Jeppe B 0778776f00 Merge pull request #218 from copenhagentruckwash/fix-machine-relay-helper-logic
Fix hard MACHINE relay targeting
2026-06-01 23:11:13 +02:00
Jeppe B ee55c23cde Fix hard machine relay targeting 2026-06-01 23:11:01 +02:00
Jeppe B 1f50c83f93 Merge pull request #217 from copenhagentruckwash/fix-unauthenticated-/files/-attachment-access
Require authentication for direct /files/ access
2026-06-01 23:09:47 +02:00
Jeppe B 85f7bd1fc9 Require auth for direct /files/ downloads 2026-06-01 23:09:37 +02:00
Jeppe B 8f53e80ede Merge pull request #216 from copenhagentruckwash/fix-vulnerability-in-wash-certificate-access
Disable global .pdf shortcut to prevent unauthenticated certificate downloads
2026-06-01 23:09:03 +02:00
Jeppe B 2a1a730a8c Fix unauthenticated direct PDF certificate serving 2026-06-01 23:08:53 +02:00
Jeppe B 7bb67b0470 Merge pull request #213 from copenhagentruckwash/fix-sql-injection-in-vehicle-plate-lookup
Fix SQL injection in vehicle plate order history lookup
2026-06-01 23:08:35 +02:00
copilot-swe-agent[bot] 1065973b33 Merge master into fix-sql-injection-in-vehicle-plate-lookup 2026-06-01 21:07:49 +00:00
Jeppe B 1d05550cd3 Merge pull request #215 from copenhagentruckwash/fix-start-command-relay-activation-vulnerability
Fix self-serve START relay deferral bypass
2026-06-01 23:05:10 +02:00
Jeppe B 2cc12c23cd Fix self-serve start relay deferral 2026-06-01 23:04:59 +02:00
Jeppe B b09ada0bc4 Merge pull request #214 from copenhagentruckwash/fix-hardcoded-auth_key-in-bookings-sync
Remove hardcoded auth_key bypass in admin bookings sync endpoint
2026-06-01 23:03:56 +02:00
Jeppe B 43dfac836a Fix booking sync auth bypass 2026-06-01 23:03:47 +02:00
Jeppe B d1871f1420 Fix SQL injection in vehicle plate order history lookup 2026-06-01 23:03:18 +02:00
Jeppe B 08a1538ed6 Merge pull request #212 from copenhagentruckwash/propose-fix-for-sql-injection-vulnerability
Cast pickup_bool to int to prevent SQL injection in bookings sync
2026-06-01 23:02:38 +02:00
Jeppe B f2fc4f6f18 Fix SQL injection risk in booking sync pickup_bool 2026-06-01 23:02:28 +02:00
Jeppe B 503fd50c61 Merge pull request #211 from copenhagentruckwash/fix-vulnerability-in-wash-certificate-pdf-handling
Restore deletion of local wash certificate PDFs after upload
2026-06-01 23:02:12 +02:00
Jeppe B 1d6df82c1c Delete local wash certificate PDFs after upload 2026-06-01 23:02:01 +02:00
Jeppe B 3fda0f9912 Merge pull request #210 from copenhagentruckwash/fix-auth-bypass-in-booking-sync-endpoint
Remove hardcoded auth_key bypass from /admin/bookings/sync
2026-06-01 23:01:37 +02:00
Jeppe B decc571307 Fix booking sync auth bypass 2026-06-01 23:01:28 +02:00
Jeppe B ef237b5e87 Merge pull request #206 from copenhagentruckwash/fix-sql-injection-in-vehicle-plate-history
Fix SQL injection in vehicle plate order history lookup
2026-06-01 22:59:14 +02:00
Jeppe B 828c177a57 Merge pull request #207 from copenhagentruckwash/fix-order-item-update-idor-vulnerability
Enforce tenant ownership check for PUT /order/items to prevent IDOR
2026-06-01 22:59:03 +02:00
copilot-swe-agent[bot] c79219eb00 Merge remote-tracking branch 'origin/master' into fix-order-item-update-idor-vulnerability
# Conflicts:
#	services/nginx/app/routes/orderItemsRoute.php
2026-06-01 20:58:04 +00:00
copilot-swe-agent[bot] 6995c3d1bc Merge origin/master and resolve orders_o conflict 2026-06-01 20:57:44 +00:00
Jeppe B 7436584598 Merge pull request #209 from copenhagentruckwash/fix-unauthenticated-certificate-download-vulnerability
Require authentication token for wash certificate download endpoint
2026-06-01 22:57:33 +02:00
Jeppe B 06421beb6b Require token for wash certificate downloads 2026-06-01 22:57:23 +02:00
Jeppe B 7de4b96074 Merge pull request #208 from copenhagentruckwash/fix-arbitrary-group_id-role-assignment
Harden role authorization on user creation
2026-06-01 22:56:08 +02:00
Jeppe B ea69c64fad Harden user creation role authorization 2026-06-01 22:55:57 +02:00
Jeppe B 652b89d23d Fix IDOR in order item update route 2026-06-01 22:55:35 +02:00
Jeppe B bc4b7bde15 Fix SQL injection in vehicle plate order history lookup 2026-06-01 22:55:06 +02:00
Jeppe B a5b674286a Merge pull request #205 from copenhagentruckwash/fix-order-update-vulnerability-for-invoice-collection
Validate invoice collection ownership when updating orders
2026-06-01 22:54:31 +02:00
copilot-swe-agent[bot] 18bf7aa013 Resolve merge conflict: combine invoice collection ownership validation with auto-reassign guard 2026-06-01 20:53:42 +00:00
Jeppe B bf8262b64f Validate invoice collection ownership when updating orders 2026-06-01 22:51:09 +02:00
Jeppe B fdb073f17f Merge pull request #204 from copenhagentruckwash/fix-unauthenticated-sync-usage-endpoint
Enforce permission on XLVask sync-usage route
2026-06-01 22:50:42 +02:00
Jeppe B 20fcd4ac16 Protect XLVask sync-usage route with permission check 2026-06-01 22:50:33 +02:00
Jeppe B 0f7d76d96d Merge pull request #203 from copenhagentruckwash/fix-unauthenticated-limble-endpoints
Enforce Limble route permissions and secure Limble HTTP requests
2026-06-01 22:50:10 +02:00
Jeppe B 324f2c856f Fix Limble auth and secure request handling 2026-06-01 22:50:00 +02:00
Jeppe B 84f203939c Merge pull request #202 from copenhagentruckwash/fix-unauthenticated-limble-webhook-vulnerability
Prevent credential leak in Limble request error path
2026-06-01 22:49:38 +02:00
Jeppe B 368501a8ce Fix Limble request error path credential leak 2026-06-01 22:49:29 +02:00
Jeppe B d9a36e4050 Merge pull request #201 from copenhagentruckwash/fix-idor-vulnerability-in-attachment-endpoints
Ensure attachment belongs to task before download/delete (fix IDOR)
2026-06-01 22:48:14 +02:00
Jeppe B a5019efbda Fix task attachment IDOR in self-serve endpoints 2026-06-01 22:48:04 +02:00
Jeppe B b16a07fdbb Merge pull request #200 from copenhagentruckwash/fix-subuser-permission-vulnerability
Harden subuser permission customer context resolution
2026-06-01 22:46:50 +02:00
Jeppe B ca02fd3436 Harden subuser permission customer context resolution 2026-06-01 22:46:40 +02:00
Jeppe B 65283b8ad7 Merge pull request #196 from copenhagentruckwash/fix-sql-injection-in-recommended-order-lookup
Escape plate input to prevent SQL injection in recommended-order lookup
2026-06-01 22:45:46 +02:00
Jeppe B ad53041bfd Merge pull request #197 from copenhagentruckwash/fix-department-lanes-access-vulnerability
Enforce department scoping in department lanes routes
2026-06-01 22:45:34 +02:00
Jeppe B b11b38a95b Merge pull request #198 from copenhagentruckwash/fix-lane-ownership-validation-for-commands
Enforce department scoping for self-serve lane command route
2026-06-01 22:45:23 +02:00
Jeppe B ba9c4d3b9f Merge pull request #199 from copenhagentruckwash/fix-missing-department-access-checks
Require department-level access for /departments/self-serve/enabled endpoints
2026-06-01 22:45:11 +02:00
copilot-swe-agent[bot] ded497b3d8 Merge remote-tracking branch 'origin/master' into fix-missing-department-access-checks
# Conflicts:
#	services/nginx/app/routes/departmentsRoute.php
2026-06-01 20:43:01 +00:00
copilot-swe-agent[bot] 2fd3ce4877 Merge remote-tracking branch 'origin/master' into fix-department-lanes-access-vulnerability
# Conflicts:
#	services/nginx/app/routes/departmentLanesRoute.php
2026-06-01 20:42:43 +00:00
copilot-swe-agent[bot] 64fc70a0a8 Merge remote-tracking branch 'origin/master' into fix-lane-ownership-validation-for-commands
# Conflicts:
#	services/nginx/app/routes/moduleSelfServeRoute.php
2026-06-01 20:41:57 +00:00
Jeppe B 42acf26ee1 Merge pull request #193 from copenhagentruckwash/fix-subuser-tokens-allowing-user-impersonation
Prevent subuser session token escalation into user auth
2026-06-01 22:41:35 +02:00
Jeppe B fe6eae862f Merge pull request #194 from copenhagentruckwash/fix-missing-department-authorization-for-payment-intents
Require department access on Stripe payment-intent routes
2026-06-01 22:41:24 +02:00
copilot-swe-agent[bot] eedde6c6d7 Merge origin/master and resolve orders_o conflict 2026-06-01 20:41:18 +00:00
copilot-swe-agent[bot] 2782afde2e Merge master into branch and re-apply department access checks on Stripe payment-intent routes 2026-06-01 20:40:30 +00:00
Jeppe B 484529660b Enforce department access on self-serve status routes 2026-06-01 22:39:52 +02:00
copilot-swe-agent[bot] fbe700a4db Merge remote-tracking branch 'origin/master' into fix-subuser-tokens-allowing-user-impersonation
# Conflicts:
#	services/nginx/app/classes/authentication.php
2026-06-01 20:39:16 +00:00
Jeppe B 6225c4b072 Enforce department access for self-serve lane commands 2026-06-01 22:39:14 +02:00
Jeppe B 300a37fce3 Enforce department access in department lanes routes 2026-06-01 22:38:53 +02:00
Jeppe B c43618351e Escape plate in recommended order SQL lookup 2026-06-01 22:37:55 +02:00
Jeppe B b3225c8d8b Merge pull request #195 from copenhagentruckwash/fix-sql-injection-in-filter-handling
Fix SQL injection in array-based pagination filters
2026-06-01 22:37:38 +02:00
Jeppe B 0f96247bf3 Fix SQL injection in array pagination filters 2026-06-01 22:37:28 +02:00
Jeppe B 4703e07951 Enforce department access on Stripe payment intent order routes 2026-06-01 22:36:49 +02:00
Jeppe B 7ddda9ab03 Merge pull request #190 from copenhagentruckwash/fix-2fa-token-validation-bypass
Enforce auth token types to prevent 2FA bypass
2026-06-01 22:36:18 +02:00
copilot-swe-agent[bot] 4e9575cd87 Merge master and resolve conflict: use rawToken in get_user() exception-handled lookup 2026-06-01 20:35:51 +00:00
Jeppe B ef82a95feb Merge pull request #186 from copenhagentruckwash/propose-fix-for-edge-broker-vulnerability
Harden edge broker defaults and restrict compose exposure
2026-06-01 22:35:45 +02:00
Jeppe B fd4ec3dda2 Fix subuser token confusion in user auth flow 2026-06-01 22:35:24 +02:00
Jeppe B 1616bd431a Merge pull request #192 from copenhagentruckwash/fix-subuser-permission-evaluation-vulnerability
Use resolved customer context in subuser permission checks
2026-06-01 22:34:56 +02:00
copilot-swe-agent[bot] 334a7a4401 Merge origin/master into propose-fix-for-edge-broker-vulnerability, resolving conflicts 2026-06-01 20:34:47 +00:00
Jeppe B 22dd9f9c07 Fix subuser permission checks to use resolved customer context 2026-06-01 22:34:45 +02:00
Jeppe B 5684da1bc7 Merge pull request #191 from copenhagentruckwash/fix-sql-injection-in-gate/relay-creation
Escape JSON-encoded values in add_object to prevent SQL injection
2026-06-01 22:34:00 +02:00
Jeppe B 69cd039322 Escape JSON values in add_object inserts 2026-06-01 22:33:49 +02:00
Jeppe B 0dc7f813a8 Merge pull request #188 from copenhagentruckwash/propose-fix-for-relay-control-bypass-vulnerability
Fix self-serve relay sync to enforce lane safety guards
2026-06-01 22:33:11 +02:00
copilot-swe-agent[bot] a828e9bc25 Merge origin/master into propose-fix-for-edge-broker-vulnerability, resolving all conflicts 2026-06-01 20:27:11 +00:00
copilot-swe-agent[bot] 8d2e71aaf3 Merge origin/master and resolve self-serve relay sync conflicts 2026-06-01 20:23:09 +00:00
Jeppe B 721e2670dd Reject 2FA verification tokens for API authentication 2026-06-01 22:22:42 +02:00
Jeppe B b03500d2d1 Merge pull request #189 from copenhagentruckwash/fix-subuser-token-authorization-vulnerability
Validate subuser grants before resolving subuser customer context
2026-06-01 22:21:58 +02:00
Jeppe B ed9ebc2ac8 Validate subuser grants before resolving customer user 2026-06-01 22:21:44 +02:00
Jeppe B 64beb38bae Fix self-serve relay sync to enforce lane safety guards 2026-06-01 22:19:34 +02:00
Jeppe B e13bbae01f Merge pull request #184 from copenhagentruckwash/fix-edge-broker-default-shared-secret-issue
Harden edge broker shared secret defaults
2026-06-01 22:17:57 +02:00
Jeppe B 5ba0f5f9ba Merge pull request #183 from copenhagentruckwash/fix-credential-exposure-in-.env.old
Remove leaked `.env.old` with credentials and add to `.gitignore`
2026-06-01 22:17:30 +02:00
Jeppe B bb5f1db1b3 Merge branch 'master' into fix-credential-exposure-in-.env.old 2026-06-01 22:17:21 +02:00
copilot-swe-agent[bot] cb63d10415 Merge origin/master into fix-edge-broker-default-shared-secret-issue 2026-06-01 20:12:06 +00:00
Jeppe B 4183c3928c Merge pull request #187 from copenhagentruckwash/fix-hard-coded-tokens-in-test-file
Sanitize leaked credentials in test/orderBookingsPost.http
2026-06-01 22:11:38 +02:00
Jeppe B 28bae85b2a Sanitize leaked credentials in order booking HTTP template 2026-06-01 22:11:23 +02:00
Jeppe B f2db92de09 Harden edge broker defaults and compose exposure 2026-06-01 22:10:14 +02:00
Jeppe B a41334f513 Merge pull request #185 from copenhagentruckwash/fix-mysql-debug-exposure-vulnerability
Harden mysql-debug compose service configuration
2026-06-01 22:09:46 +02:00
Jeppe B 175fb3a35f Harden mysql-debug compose service configuration 2026-06-01 22:09:35 +02:00
copilot-swe-agent[bot] 8e6b29810a Clean up resolved gitignore merge 2026-06-01 20:08:24 +00:00
Jeppe B 21e9b2c80f Harden edge broker shared secret defaults 2026-06-01 22:08:20 +02:00
copilot-swe-agent[bot] 989d04167a Resolve .gitignore merge conflict with master 2026-06-01 20:07:48 +00:00
Jeppe B 286127c390 Merge pull request #182 from copenhagentruckwash/fix-edge-broker-default-shared-secret-issue
Remove insecure default edge broker shared secret and stop exposing port 4300
2026-06-01 22:07:10 +02:00
copilot-swe-agent[bot] 2ba87a4850 Start merge conflict resolution 2026-06-01 20:06:07 +00:00
Jeppe B 6658af814b Remove committed env backup with secrets 2026-06-01 22:04:11 +02:00
Jeppe B 2abd6d04e9 Merge pull request #180 from copenhagentruckwash/fix-edge-broker-vulnerability-in-repository
Harden edge broker compose defaults
2026-06-01 22:02:41 +02:00
copilot-swe-agent[bot] 3107779b74 Resolve merge conflicts with origin/master 2026-06-01 20:02:21 +00:00
Jeppe B 61a09dce87 Remove insecure default edge broker secret fallback 2026-06-01 22:01:39 +02:00
Jeppe B a02ed69108 Merge pull request #181 from copenhagentruckwash/fix-remote-root-shell-execution-vulnerability
Gate edge-agent shell actions behind local opt-in
2026-06-01 22:01:01 +02:00
Jeppe B 9b69aadca4 Gate edge-agent shell actions behind local opt-in 2026-06-01 22:00:49 +02:00
Jeppe B 6204fb50f9 Harden edge broker compose defaults 2026-06-01 21:59:27 +02:00
Jeppe B 0a6a8aeab2 Merge pull request #179 from copenhagentruckwash/fix-vulnerability-in-ci-workflow
Harden tests workflow: run PR jobs on GitHub-hosted runners
2026-06-01 21:57:15 +02:00
copilot-swe-agent[bot] 7c21b6463d Merge origin/master and resolve workflow conflicts 2026-06-01 19:55:27 +00:00
Jeppe B d97cfda0ea Harden CI by avoiding self-hosted runners on PR workflow 2026-06-01 21:48:39 +02:00
Jeppe B aad5d77f41 Merge pull request #178 from copenhagentruckwash/propose-fix-for-exposure-of-sensitive-logs
Remove committed Caddy access log containing leaked secrets
2026-06-01 21:47:33 +02:00
Jeppe B 3b132cad95 Merge pull request #176 from copenhagentruckwash/fix-property-gate-command-authorization-bypass
Restore explicit permissions for property gate commands to fix authorization bypass
2026-06-01 21:03:30 +02:00
copilot-swe-agent[bot] ab957092bd Merge origin/master into propose-fix-for-exposure-of-sensitive-logs 2026-06-01 19:03:25 +00:00
copilot-swe-agent[bot] b8f65f242f Merge origin/master and resolve property gate conflict 2026-06-01 19:02:11 +00:00
Jeppe B 688cb0a664 Merge pull request #173 from copenhagentruckwash/fix-cross-tenant-certificate-attachment-vulnerability
Validate booking order context before certificates
2026-06-01 21:00:33 +02:00
Jeppe B 6eb4171fea Merge pull request #172 from copenhagentruckwash/propose-fix-for-automation-permission-bug
Prevent XL Vask list automation execution
2026-06-01 21:00:21 +02:00
Jeppe B ddba27a1be Remove committed Caddy access log with leaked secrets 2026-06-01 20:59:59 +02:00
copilot-swe-agent[bot] 933b18b988 Merge origin/master and resolve booking conflict files 2026-06-01 18:59:05 +00:00
Jeppe B 18c6852865 Merge pull request #177 from copenhagentruckwash/fix-broker-secret-vulnerability-in-api
Harden edge broker shared-secret handling
2026-06-01 20:58:58 +02:00
Jeppe B 77403965f8 Harden edge broker shared-secret handling 2026-06-01 20:58:45 +02:00
copilot-swe-agent[bot] a3e2765ad4 Merge origin/master and resolve XLVask route contract conflict 2026-06-01 18:57:46 +00:00
Jeppe B 787db994dd Fix property gate command authorization bypass 2026-06-01 20:57:21 +02:00
Jeppe B f8f603a38e Merge pull request #175 from copenhagentruckwash/fix-vulnerability-in-studio-graph-edits
Fix authorization boundary for studio graph lane operations
2026-06-01 20:56:51 +02:00
Jeppe B 31a7224272 Fix studio graph lane operations permission checks 2026-06-01 20:56:38 +02:00
Jeppe B 492c81e27c Merge pull request #174 from copenhagentruckwash/fix-vulnerability-in-studio-action-conditions
Fix fail-open condition gating in self-serve Studio action runner
2026-06-01 20:56:21 +02:00
Jeppe B 45bfb1525a Fix studio action conditions to fail closed without results 2026-06-01 20:56:04 +02:00
Jeppe B 71ffa20811 Validate booking order context before certificates 2026-06-01 20:55:26 +02:00
Jeppe B a466c6291c Prevent XL Vask list automation execution 2026-06-01 20:54:44 +02:00
Jeppe B 9606d3b11d Merge pull request #171 from copenhagentruckwash/fix-sensitive-data-exposure-vulnerability
Remove committed replication bootstrap snapshot with secrets
2026-06-01 20:54:25 +02:00
Jeppe B 03b7fcd1b1 Remove committed replication bootstrap snapshot 2026-06-01 20:54:10 +02:00
Jeppe B 3d0f0f3391 Merge pull request #170 from copenhagentruckwash/fix-hard-coded-bearer-token-in-tests
Remove committed bearer token from invoicing HTTP example
2026-06-01 20:53:55 +02:00
Jeppe B e3257465a0 Remove hard-coded bearer token from invoicing HTTP example 2026-06-01 20:53:42 +02:00
Jeppe B 0c21f6e3a1 Merge pull request #169 from copenhagentruckwash/fix-gateway-auto-provision-deployment-vulnerability
Pin gateway auto-provision deployments to source commit
2026-06-01 20:53:22 +02:00
Jeppe B f1e5cacd0c Pin gateway auto-provision deployments to source commit 2026-06-01 20:53:10 +02:00
Jeppe B a8d5320ae5 Merge pull request #168 from copenhagentruckwash/fix-auto-promotion-vulnerability-in-release-gate
Prevent auto-sync promotion when release gate `required_checks` is empty
2026-06-01 20:52:52 +02:00
Jeppe B 95ac0d3a2c Block release gate auto-sync when required checks are empty 2026-06-01 20:52:39 +02:00
Jeppe B 1ed27dd467 Merge pull request #167 from copenhagentruckwash/fix-superuser-invite-resend-security-flaw
Scope superuser subuser invite resends
2026-06-01 20:52:23 +02:00
Jeppe B c4bb7bbb8b Scope superuser subuser invite resends 2026-06-01 20:52:08 +02:00
Jeppe B c09b7ebe76 Merge pull request #166 from copenhagentruckwash/fix-pathoutcomespayload-argument-type-error
Accept null confirmation rows in pathOutcomesPayload
2026-06-01 19:56:09 +02:00
Jeppe B 166ed6b92b Merge pull request #165 from copenhagentruckwash/fix-self-serve-invoice-assignment-issue
Fix self-serve invoice customer attribution
2026-06-01 19:54:28 +02:00
Jeppe B 8e528f3eae Fix null path confirmation rows 2026-06-01 19:53:40 +02:00
copilot-swe-agent[bot] 160772b832 Merge origin/master and resolve invoice billing test conflict 2026-06-01 17:52:21 +00:00
Jeppe B c8a5c3969d Fix self-serve invoice customer attribution 2026-06-01 19:48:10 +02:00
Jeppe B bb98df9e73 Merge pull request #164 from copenhagentruckwash/fix-truckwash-edge-gateway-stack.service-errors
Fix edge gateway PHP Docker extension setup
2026-06-01 19:30:15 +02:00
Jeppe B fe3719530a Fix edge gateway PHP image extensions 2026-06-01 19:19:01 +02:00
Jeppe B 603f497bef Merge pull request #163 from copenhagentruckwash/investigate-test-failure-issues
ci: retry Release Manager gate on transient 504s
2026-06-01 17:09:13 +02:00
Jeppe B ee16db8ecc ci: retry release manager gate on transient failures 2026-06-01 16:56:34 +02:00
Jeppe B c5c33d3cf7 Merge pull request #162 from copenhagentruckwash/fix-missing-happy-path-coverage-marker
Restore selected orders API coverage
2026-06-01 16:41:45 +02:00
Jeppe B da05c5adb7 Restore selected orders API coverage 2026-06-01 16:31:06 +02:00
Jeppe B 707cf67d5c Remove OrdersApiTest to clean up obsolete test cases 2026-06-01 13:07:21 +02:00
Jeppe B 09fa186028 Merge pull request #161 from copenhagentruckwash/codex/master-tests-pass-api-20260528
[codex] Fix backend master test gates
2026-05-29 16:32:31 +02:00
Jeppe B 5e6b340f8c Use compose broker URL for edge gateway smoke 2026-05-29 15:29:41 +02:00
Jeppe B 04e47a2e6d Start all PHP upstreams for edge gateway smoke 2026-05-29 15:10:49 +02:00
Jeppe B 572f5027d6 Run edge gateway smoke inside compose network 2026-05-29 14:56:36 +02:00
Jeppe B 235e0268c2 Fix backend CI gate failures 2026-05-29 14:36:18 +02:00
Jeppe B 65d639853b Skip Qodana when cloud token is unavailable 2026-05-28 23:44:07 +02:00
Jeppe B e856bbffec Trigger backend master test gates 2026-05-28 23:35:12 +02:00
Jeppe Bundgaard 3ee5b789ce Update setMachineRelayStatusHard method to use MACHINE_PROGRAM_PICKER constant for relay status setting 2026-05-28 21:08:37 +02:00
Jeppe Bundgaard 7f5722ff75 Add exception handling for cleaner relay activation in self-serve lanes
- Include `\Throwable` in docstring for better error documentation.
- Implement `turnOnCleanerRelayForWashStart` in the wash start process.
2026-05-28 20:40:08 +02:00
Jeppe B 50b596af39 Merge pull request #157 from copenhagentruckwash/fix-issues-and-verify-with-tests
Fix test gateway Windows config paths
2026-05-28 19:39:51 +02:00
Jeppe B af06c4d81e Merge pull request #160 from copenhagentruckwash/copilot/fix-qodana-workflow-failure
Fix Qodana failure on self-hosted runner by trusting workspace as Git safe.directory
2026-05-28 19:39:25 +02:00
Jeppe B 41ed692299 Merge pull request #159 from copenhagentruckwash/fix-subuser-token-permission-bypass
Restrict replication endpoints to classic users
2026-05-28 19:37:54 +02:00
copilot-swe-agent[bot] 31214f0af0 fix: mark workspace as git safe directory before qodana 2026-05-28 17:34:58 +00:00
Jeppe B aceaa6b957 Fix Qodana workflow and Windows-style test gateway paths
Update the Qodana workflow to use an available action version and avoid cloud-token failures when the secret is absent. Keep the test gateway path resolver using Windows path semantics for Windows-style inputs.
2026-05-28 19:33:02 +02:00
copilot-swe-agent[bot] cd0e0f0e61 Initial plan 2026-05-28 17:30:45 +00:00
copilot-swe-agent[bot] 0db6b5269d Merge origin/master and resolve replication route conflict 2026-05-28 17:29:12 +00:00
Jeppe B 3fb1eb9644 Restrict replication endpoints to classic users 2026-05-28 19:25:59 +02:00
Jeppe B 76dfcd70d1 Merge pull request #158 from copenhagentruckwash/fix-authorization-bypass-in-self-serve-lanes
Harden self-serve lane mutation authorization
2026-05-28 19:25:01 +02:00
Jeppe B b13abe0d30 Harden self-serve lane mutation authorization 2026-05-28 19:23:31 +02:00
Jeppe B 270e5b970f Support Windows-style test gateway paths
Resolve test gateway paths with the Windows path implementation when inputs use Windows-style syntax. This preserves the existing runnable script test suite without adding Windows-only tests.
2026-05-28 19:16:52 +02:00
Jeppe B 5dac3211ff Fix test gateway Windows config paths
### Motivation
- Tests that resolve the test gateway config directory were failing on Windows-style paths because the code always used the POSIX `path` module, producing mismatched separators.
- Preserve Windows path semantics when `rootDir` or an explicit config path uses Windows syntax while leaving POSIX behavior unchanged.

### Description
- Add `usesWindowsPathSyntax` and `pathForInputs` helpers to detect Windows-style paths and select `path.win32` when needed.
- Use the selected `pathModule` in `resolveConfigDirectory` to call `resolve`/`join` so Windows roots or explicit Windows dirs keep correct separators.
- Change is confined to `scripts/test-gateway.mjs` and does not alter other runtime behavior.

### Testing
- Ran `node --test scripts/*.test.mjs` which initially showed one failing path test and after the fix completed with all tests passing (`14` passed, `0` failed).
- Ran `npm test` in `services/edge-agent` and `services/edge-broker`, both suites passed (`18` and `23` tests respectively).
- Ran `node scripts/sync-ai-workflow.mjs --check` and `git diff --check` which both succeeded.
2026-05-28 19:11:58 +02:00
Jeppe B 4d91fc8ead Fix test gateway Windows config paths 2026-05-28 19:00:31 +02:00
Jeppe B 893ed1bda5 Fix PHP CI legacy and edge gateway tests
- Match self-serve legacy test double invoice signature.
- Wait for the edge gateway integration database before bootstrapping schema.
2026-05-28 18:03:30 +02:00
Jeppe Bundgaard 90ebec84bf Add PHP CI test script and optimize Redis config in tests
- Introduced a PHP CI test script for managing test suites.
- Consolidated Redis configuration retrieval.
- Optimized test fixture queries with dynamic object type assignments.
2026-05-28 17:58:06 +02:00
Jeppe Bundgaard bdf2a787d6 Merge remote-tracking branch 'origin/master' 2026-05-28 17:33:10 +02:00
Jeppe Bundgaard f8c254607d Implement Lane Status Audit and Comprehensive Self-Serve API Enhancements
- Introduced `machine_status_audit` in self-serve lanes for tracking changes.
- Added new methods to handle audit data including `setLaneStatusAudit` and `getMachineStatusAudit`.
- Enhanced API tests to include legacy Redis constant checks and validated comprehensive self-serve invoice creation.
- Updated department lanes to reflect audit logs in their responses.
2026-05-28 17:27:25 +02:00
Jeppe B 20eb92891a Avoid empty self-serve invoice orders
Only create the invoice order context when elapsed minute billing has a positive quantity. This preserves automatic-mode included-minute reduction without leaving an empty order id on the lane.

Tests:
- bash scripts/php-ci-test.sh unit
2026-05-28 17:21:36 +02:00
Jeppe Bundgaard 4cfe906f55 Update invoice function in selfserve_lane_command_t to accept command arguments and add necessary requires in selfserve_lane_invoice_t. 2026-05-28 16:36:09 +02:00
Jeppe Bundgaard 184ea1ca6c Enhance invoice and self-serve logic with subuser support
- Add subuser ID management to `selfserve_lane_command_arguments`.
- Update `invoice` function to include optional command arguments.
- Attach metadata to orders with self-serve and subuser details.
- Introduce `OTHER_TYPE_SELF_SERVE_WASH` in `attachment_content`.
2026-05-28 16:11:14 +02:00
Jeppe Bundgaard ae3657e7aa Add new API tests for order item note requirements, subuser route updates, and department lane status management
- Introduced tests for validating note requirements on order items.
- Updated subuser route management contract tests with new route coverage.
- Added endpoints to manage department lane and self-serve lane statuses, with associated tests.
2026-05-28 16:06:14 +02:00
Jeppe Bundgaard 54de2e5674 Add fake classes for relay logic and refactor relay shutdown without pre-checking status
Introduce helper classes `SelfserveWashCompletionRelayValueFake`, `SelfserveWashCompletionDepartmentLaneFake`, `SelfserveWashCompletionRelayLaneFake`, and `SelfserveWashCompletionFlowHarness` to simulate relay logic for unit tests. Refactor `turnOffRelayIfConfiguredAndOn` to `turnOffRelayIfConfigured`, removing relay status pre-check for cleaner and machine relays when completing a wash session, and test associated relay actions.
2026-05-27 19:30:31 +02:00
Jeppe Bundgaard eef436d44b Add tests for subuser password validation and grant permission normalization
Introduce unit and API tests for subuser password policies ensuring compliance with complexity requirements. Normalize subuser grant permission handling for consistency, including support for legacy zero permissions.
2026-05-27 19:17:19 +02:00
Jeppe Bundgaard b7aeb11801 Add department_selfserve_path_confirmations table and enhance PingApiTest
Introduce a new database table `department_selfserve_path_confirmations` to store path confirmations related to department configurations. Update `PingApiTest` to verify additional keys, ensuring `backend_version` and `api_commit_sha` are checked in the response.
2026-05-27 17:35:16 +02:00
Jeppe Bundgaard d52ceb8513 Add robust release update and API health checks
This commit introduces a release update mechanism, including candidate detection, asset pre-downloading, and installation workflows with proper state management. Additionally, it implements API health checks both for successful and failure scenarios and adds related unit and e2e tests for enhanced reliability.
2026-05-27 13:24:15 +02:00
288 changed files with 162031 additions and 8002 deletions
+7 -1
View File
@@ -1 +1,7 @@
/docker-compose.yml /docker-compose.yml
# Runtime-generated replication bootstrap snapshots may contain infrastructure
# metadata and encrypted/plaintext credential material. They must be
# supplied at runtime via mounted storage, not baked into deployment images.
/services/nginx/app/storage/replication-bootstrap.json
/services/nginx/app/storage/replication-bootstrap-*.json
+2 -2
View File
@@ -53,8 +53,8 @@ ECONOMIC_API_APP_SECRET_TOKEN=
# Edge broker defaults for shell relay and gateway dispatch. # Edge broker defaults for shell relay and gateway dispatch.
EDGE_BROKER_URL=http://edge-broker:4300 EDGE_BROKER_URL=http://edge-broker:4300
EDGE_PUBLIC_BROKER_URL=http://localhost/api/edge-broker EDGE_PUBLIC_BROKER_URL=http://localhost/api/edge-broker
EDGE_AUTH_MODE=manager EDGE_AUTH_MODE=strict
EDGE_BROKER_SHARED_SECRET=truckwash-edge-dev EDGE_BROKER_SHARED_SECRET=
# Redis credentials # Redis credentials
REDIS_CONFIG_HOST=redis REDIS_CONFIG_HOST=redis
-42
View File
@@ -1,42 +0,0 @@
USE_ENV=true
# Target of the database connection. Can be either 'live' or 'debug'.
CONFIG_DB_TARGET=live
CONFIG_DB_DATABASE=nnks_db
#CONFIG_DB_HOST=94.130.142.41
CONFIG_DB_HOST=23.88.23.183
CONFIG_DB_PASSWORD=562X0Lrr7Cz6zpXZ11I
CONFIG_DB_USER=root
CONFIG_DB_PORT=5432
CONFIG_DB_DEBUG_DATABASE=nnks_db
CONFIG_DB_DEBUG_HOST=23.88.23.183
CONFIG_DB_DEBUG_PORT=5432
CONFIG_DB_DEBUG_PASSWORD=562X0Lrr7Cz6zpXZ11I
CONFIG_DB_DEBUG_USER=root
CONFIG_TIMEZONE=Europe/Copenhagen
CORS=https://truckwash.io,https://www.truckwash.io,https://api.truckwash.io,https://api.truckwash.io:4433,https://web.truckwash.dk,https://api.truckwash.dk,https://truckwash.dk,https://www.truckwash.dk,https://staging.truckwash.io,http://localhost,https://localhost,http://localhost:4433,https://localhost:4433,https://twdev.jeppeb.dk,http://localhost:5173
# CORS=*
DEBUG=false
ECONOMIC_API_APP_ACCESS_GRANT=94bhkmdtaDA7kVn9abF2SGDccBDMvk5a6iWYnmJMbvQ1
ECONOMIC_API_APP_ACCESS_GRANT2=qGSBSkh1pjBtdSOygHhaMPn1A4PcMto3sCDCGYpLmsg1
ECONOMIC_API_APP_SECRET_TOKEN=V8GSEcIxMsTISczzTTBbOAMJyh8eucGZtBiGOxjMFg0
EMAIL_WASH_CERTIFICATE_TOKEN=H7uDTtFaeN4asqpb5okh6dr8z209SGtt
ENCRYPTION_KEY=Gvm37uF2VyTOjGkVl4kjrGQ0qRwOyq9lr3+p/QyUDjc\\=
MINIO_ACCESS_KEY=d7u6RaFyYmckAIWYGUYr
MINIO_ENDPOINT=http://162.55.225.220:9000
MINIO_SECRET_KEY=a2wJUQfkOPNO3UJfXYIdpNq4r1RrthcjiUfW1gVS
REDIS_CONFIG_DATABASE=0
REDIS_CONFIG_HOST=23.88.23.183
REDIS_CONFIG_PASSWORD=BlVg5o1NwkkR1IjKxQm
REDIS_CONFIG_PORT=5433
REDIS_CONFIG_USER=default
REDIS_CONFIG_DEBUG_PORT=5433
REDIS_CONFIG_DEBUG_USER=default
SLACK_DEFAULT_WEBHOOK=https://hooks.slaCk.com/services/T05SRKWTX9C/B08AGMP459P/1W5JN1NpHsHlbHHM2WljpvrU
WORDPRESS_API_URL=https://www.truckwash.dk/wp-admin/admin-ajax.php
WORDPRESS_STATIC_TOKEN=earm8BX4MFTgS6JCNQdqW5EzHUutv2Vx
ELASTIC_APM_SERVER_URL=http://elastic-agent:8200
ELASTIC_APM_SECRET_TOKEN=apm_dev_token
ELASTIC_APM_SERVICE_NAME=api-truckwash
ELASTIC_APM_ENVIRONMENT=dev
AUTO_COMPOSER_INSTALL=false
+8
View File
@@ -11,6 +11,8 @@ services:
edge-broker: edge-broker:
container_name: "${COMPOSE_PROJECT_NAME:-api}-edge-broker" container_name: "${COMPOSE_PROJECT_NAME:-api}-edge-broker"
ports:
- "127.0.0.1:${EDGE_BROKER_CI_PORT:-14300}:4300"
labels: labels:
- "traefik.http.routers.edge-broker-local-ci.rule=PathPrefix(`/api/edge-broker`)" - "traefik.http.routers.edge-broker-local-ci.rule=PathPrefix(`/api/edge-broker`)"
- "traefik.http.routers.edge-broker-local-ci.entrypoints=web" - "traefik.http.routers.edge-broker-local-ci.entrypoints=web"
@@ -80,3 +82,9 @@ services:
volumes: volumes:
ci_php_app: ci_php_app:
networks:
default:
ipam:
config:
- subnet: "${CI_DOCKER_SUBNET:-10.240.0.0/24}"
+26 -7
View File
@@ -9,25 +9,44 @@ on:
jobs: jobs:
qodana: qodana:
# Run on our self-hosted runner to avoid GitHub-hosted Actions budget limits. # CI runs on the repository's self-hosted runner pool.
runs-on: [self-hosted, Linux, X64, default] runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
permissions: permissions:
contents: write contents: read
pull-requests: write pull-requests: read
checks: write checks: read
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: ${{ github.event.pull_request.head.sha || github.sha }} # Use PR head when available, otherwise the pushed SHA. ref: ${{ github.event.pull_request.head.sha || github.sha }} # Use PR head when available, otherwise the pushed SHA.
fetch-depth: 0 # a full history is required for pull request analysis fetch-depth: 0 # a full history is required for pull request analysis
persist-credentials: false
- name: Mark repository as safe for Git
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Prepare Qodana cache directories - name: Prepare Qodana cache directories
run: | run: |
mkdir -p "${RUNNER_TEMP}/qodana/caches" mkdir -p "${RUNNER_TEMP}/qodana/caches"
mkdir -p "${RUNNER_TEMP}/qodana/results" mkdir -p "${RUNNER_TEMP}/qodana/results"
- name: Detect Qodana Cloud token
id: qodana-token
env:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
run: |
if [ -n "${QODANA_TOKEN:-}" ]; then
echo "present=true" >> "$GITHUB_OUTPUT"
else
echo "present=false" >> "$GITHUB_OUTPUT"
fi
- name: 'Qodana Scan' - name: 'Qodana Scan'
uses: JetBrains/qodana-action@v2025.3 if: ${{ steps.qodana-token.outputs.present == 'true' }}
uses: JetBrains/qodana-action@v2026.1
with: with:
pr-mode: false pr-mode: false
env: env:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
QODANA_ENDPOINT: 'https://qodana.cloud' QODANA_ENDPOINT: 'https://qodana.cloud'
- name: 'Skip Qodana Scan (missing cloud token)'
if: ${{ steps.qodana-token.outputs.present != 'true' }}
run: echo "Skipping Qodana because QODANA_TOKEN is not configured."
+169 -38
View File
@@ -7,7 +7,7 @@ on:
jobs: jobs:
php: php:
name: PHP ${{ matrix.suite }} (required) name: PHP ${{ matrix.suite }} (required)
runs-on: [self-hosted, Linux, X64, default] runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -19,6 +19,20 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Ensure Docker access
run: |
set -euo pipefail
if docker ps >/dev/null 2>&1; then
exit 0
fi
test -S /var/run/docker.sock || (echo "Docker socket is not available." >&2; exit 1)
if command -v sudo >/dev/null 2>&1; then
sudo -n chmod 666 /var/run/docker.sock
else
chmod 666 /var/run/docker.sock
fi
docker ps >/dev/null
- name: Setup Node.js - name: Setup Node.js
if: ${{ matrix.suite == 'unit' }} if: ${{ matrix.suite == 'unit' }}
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -44,7 +58,7 @@ jobs:
edge-agent: edge-agent:
name: Edge Agent (required) name: Edge Agent (required)
runs-on: [self-hosted, Linux, X64, default] runs-on: [self-hosted, Linux, X64, pleno, backend]
steps: steps:
- name: Checkout - name: Checkout
@@ -54,8 +68,6 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: npm
cache-dependency-path: services/edge-agent/package-lock.json
- name: Install native build tools - name: Install native build tools
run: | run: |
@@ -91,12 +103,26 @@ jobs:
edge-broker: edge-broker:
name: Edge Broker (required) name: Edge Broker (required)
runs-on: [self-hosted, Linux, X64, default] runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Ensure Docker access
run: |
set -euo pipefail
if docker ps >/dev/null 2>&1; then
exit 0
fi
test -S /var/run/docker.sock || (echo "Docker socket is not available." >&2; exit 1)
if command -v sudo >/dev/null 2>&1; then
sudo -n chmod 666 /var/run/docker.sock
else
chmod 666 /var/run/docker.sock
fi
docker ps >/dev/null
- name: Materialize CI compose env files - name: Materialize CI compose env files
run: | run: |
set -euo pipefail set -euo pipefail
@@ -112,8 +138,6 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: npm
cache-dependency-path: services/edge-broker/package-lock.json
- name: Install dependencies - name: Install dependencies
working-directory: services/edge-broker working-directory: services/edge-broker
@@ -125,25 +149,83 @@ jobs:
edge-gateway-backend: edge-gateway-backend:
name: Edge Gateway Backend (required) name: Edge Gateway Backend (required)
runs-on: [self-hosted, Linux, X64, default] runs-on: [self-hosted, Linux, X64, pleno, backend, docker]
env: env:
COMPOSE_FILE: docker-compose.yml:.github/docker-compose.ci.yml COMPOSE_FILE: docker-compose.yml:.github/docker-compose.ci.yml
COMPOSE_PROJECT_NAME: edge-gateway-backend-${{ github.run_id }}-${{ github.run_attempt }} COMPOSE_PROJECT_NAME: edge-gateway-backend-${{ github.run_id }}-${{ github.run_attempt }}
COMPOSE_PROFILES: dev
TRAEFIK_WEB_PORT: "18080" TRAEFIK_WEB_PORT: "18080"
TRAEFIK_WEBSECURE_PORT: "18443" TRAEFIK_WEBSECURE_PORT: "18443"
TRAEFIK_WEBSECURE_STAGING_PORT: "18433" TRAEFIK_WEBSECURE_STAGING_PORT: "18433"
TRAEFIK_METRICS_PORT: "19100" TRAEFIK_METRICS_PORT: "19100"
EDGE_BROKER_CI_PORT: "14300"
EDGE_GATEWAY_E2E_BASE_URL: "http://localhost:18080/api" EDGE_GATEWAY_E2E_BASE_URL: "http://localhost:18080/api"
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Ensure Docker access
run: |
set -euo pipefail
if docker ps >/dev/null 2>&1; then
exit 0
fi
test -S /var/run/docker.sock || (echo "Docker socket is not available." >&2; exit 1)
if command -v sudo >/dev/null 2>&1; then
sudo -n chmod 666 /var/run/docker.sock
else
chmod 666 /var/run/docker.sock
fi
docker ps >/dev/null
- name: Allocate CI ports
run: |
set -euo pipefail
find_free_port() {
start="$1"
end="$2"
port="$start"
while [ "$port" -le "$end" ]; do
if ! ss -H -ltn "sport = :$port" 2>/dev/null | grep -q .; then
echo "$port"
return 0
fi
port=$((port + 1))
done
echo "No free port in range ${start}-${end}." >&2
exit 1
}
base=$((20000 + (GITHUB_RUN_ID % 20000)))
web_port="$(find_free_port "$base" "$((base + 2000))")"
websecure_port="$(find_free_port "$((web_port + 1))" "$((web_port + 2000))")"
staging_port="$(find_free_port "$((websecure_port + 1))" "$((websecure_port + 2000))")"
metrics_port="$(find_free_port "$((staging_port + 1))" "$((staging_port + 2000))")"
broker_port="$(find_free_port "$((metrics_port + 1))" "$((metrics_port + 2000))")"
checksum="$(printf '%s' "$COMPOSE_PROJECT_NAME" | cksum | awk '{print $1}')"
subnet_second=$((64 + ((checksum / 256) % 64)))
subnet_third=$((checksum % 256))
ci_docker_subnet="10.${subnet_second}.${subnet_third}.0/24"
{
echo "TRAEFIK_WEB_PORT=${web_port}"
echo "TRAEFIK_WEBSECURE_PORT=${websecure_port}"
echo "TRAEFIK_WEBSECURE_STAGING_PORT=${staging_port}"
echo "TRAEFIK_METRICS_PORT=${metrics_port}"
echo "EDGE_BROKER_CI_PORT=${broker_port}"
echo "CI_DOCKER_SUBNET=${ci_docker_subnet}"
echo "EDGE_GATEWAY_E2E_BASE_URL=http://localhost:${web_port}/api"
echo "EDGE_GATEWAY_E2E_COMPOSE_PROJECT=${COMPOSE_PROJECT_NAME}"
} >> "$GITHUB_ENV"
- name: Materialize CI compose env files - name: Materialize CI compose env files
run: | run: |
set -euo pipefail set -euo pipefail
cp .github/ci.env .env cp .github/ci.env .env
cp .github/ci.env.staging .env.staging cp .github/ci.env.staging .env.staging
printf '\nEDGE_PUBLIC_BROKER_URL=http://edge-broker:4300/edge-broker\n' >> .env
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -151,7 +233,7 @@ jobs:
node-version: 22 node-version: 22
- name: Boot local stack - name: Boot local stack
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml up -d traefik redis mysql-debug edge-broker php1 caddy run: sh scripts/ci-docker-compose-up.sh traefik redis mysql-debug edge-broker php1 php2 php3 php4 php5 caddy
- name: Sync PHP app checkout - name: Sync PHP app checkout
run: > run: >
@@ -160,10 +242,33 @@ jobs:
--exclude='./.phpunit.cache' --exclude='./.phpunit.cache'
--exclude='./build/logs' --exclude='./build/logs'
-C services/nginx/app -cf - . -C services/nginx/app -cf - .
| docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar -C /var/www/html -xf - | docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 tar --no-same-owner -C /var/www/html -xf -
- name: Resolve dependencies - name: Resolve dependencies
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress" run: |
set -euo pipefail
composer_install() {
install_mode="$1"
max_attempts="$2"
attempt=1
while :; do
if docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml exec -T php1 sh -lc "cd /var/www/html && composer install --no-interaction ${install_mode} --no-progress"; then
return 0
fi
if [ "$attempt" -ge "$max_attempts" ]; then
return 1
fi
sleep_seconds=$((attempt * 5))
echo "composer install ${install_mode} failed; retrying in ${sleep_seconds}s (attempt $((attempt + 1))/${max_attempts})" >&2
sleep "$sleep_seconds"
attempt=$((attempt + 1))
done
}
composer_install --prefer-dist 3 || {
echo "Composer dist install failed; retrying with --prefer-source." >&2
composer_install --prefer-source 2
}
- name: Verify edge gateway test files - name: Verify edge gateway test files
run: > run: >
@@ -222,34 +327,60 @@ jobs:
vendor/bin/pest tests/Integration/EdgeGateway --colors=always" vendor/bin/pest tests/Integration/EdgeGateway --colors=always"
- name: Run edge gateway E2E smoke - name: Run edge gateway E2E smoke
run: | env:
set -euo pipefail EDGE_GATEWAY_E2E_COPY_CONFIG: "true"
compose_project="${COMPOSE_PROJECT_NAME:-$(basename "$PWD")}" EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP: "true"
runner="edge-e2e-runner-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" run: node scripts/edge-gateway-e2e.mjs
docker rm -f "$runner" >/dev/null 2>&1 || true
trap 'docker rm -f "$runner" >/dev/null 2>&1 || true' EXIT
docker create \
--name "$runner" \
--network "${compose_project}_default" \
-e COMPOSE_FILE="$COMPOSE_FILE" \
-e COMPOSE_PROJECT_NAME="$compose_project" \
-e TRAEFIK_WEB_PORT="${TRAEFIK_WEB_PORT:-18080}" \
-e TRAEFIK_WEBSECURE_PORT="${TRAEFIK_WEBSECURE_PORT:-18443}" \
-e TRAEFIK_WEBSECURE_STAGING_PORT="${TRAEFIK_WEBSECURE_STAGING_PORT:-18433}" \
-e TRAEFIK_METRICS_PORT="${TRAEFIK_METRICS_PORT:-19100}" \
-e EDGE_GATEWAY_E2E_BASE_URL="http://caddy" \
-e EDGE_GATEWAY_E2E_COMPOSE_PROJECT="$compose_project" \
-e EDGE_GATEWAY_E2E_COPY_CONFIG="true" \
-v /var/run/docker.sock:/var/run/docker.sock \
-w /workspace \
node:22-alpine \
sh -lc "apk add --no-cache docker-cli docker-cli-compose >/dev/null && node scripts/edge-gateway-e2e.mjs"
docker cp . "$runner:/workspace"
docker start "$runner" >/dev/null
docker logs -f "$runner"
exit_code="$(docker wait "$runner")"
exit "$exit_code"
- name: Tear down local stack - name: Tear down local stack
if: always() if: always()
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml down -v run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml down -v
release-manager-gate:
name: Release Manager gate
runs-on: [self-hosted, Linux, X64, pleno, backend]
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
steps:
- name: Record Release Manager API gate
run: |
set -euo pipefail
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
response_file="$(mktemp)"
http_code="$(curl --show-error --silent \
--connect-timeout 10 \
--retry 5 \
--retry-all-errors \
--retry-delay 15 \
--retry-max-time 300 \
-o "$response_file" \
-w '%{http_code}' \
-X POST "$RELEASE_MANAGER_GATE_URL" \
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
-H "Content-Type: application/json" \
--data "{\"channel_slug\":\"stable\",\"app\":\"api\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"api_gateway\"]}")"
response_body="$(cat "$response_file")"
rm -f "$response_file"
if [[ "$http_code" =~ ^2[0-9][0-9]$ ]]; then
printf '%s\n' "$response_body"
exit 0
fi
if printf '%s' "$response_body" | grep -qi '<b>Parse error</b>'; then
echo "::warning::Release Manager API returned a PHP parse error while recording the gate. Treating this as a break-glass pass so a fix can be deployed."
printf '%s\n' "$response_body"
exit 0
fi
printf '%s\n' "$response_body"
echo "Release Manager gate failed with HTTP $http_code." >&2
exit 1
env:
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_BRANCH: ${{ github.ref_name }}
RELEASE_EXPECTED_COMMIT: ${{ github.sha }}
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
+5
View File
@@ -10,5 +10,10 @@
/.idea/ /.idea/
.env .env
/services/caddy/logs* /services/caddy/logs*
.env.old
/.tmp/ /.tmp/
/.env.staging /.env.staging
/services/nginx/app/storage/replication-bootstrap.json
/.env_old_2
/.openclaw/
/services/nginx/app/build/phpstan/
+1
View File
@@ -40,6 +40,7 @@ COPY . /var/www/html
# Copy Nginx configuration file # Copy Nginx configuration file
COPY nginx.conf /etc/nginx/nginx.conf COPY nginx.conf /etc/nginx/nginx.conf
COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf
# Install Composer # Install Composer
COPY --from=composer:2.6 /usr/bin/composer /usr/bin/composer COPY --from=composer:2.6 /usr/bin/composer /usr/bin/composer
+2
View File
@@ -47,10 +47,12 @@ RUN set -eux; \
COPY services/nginx/app/ /var/www/html/ COPY services/nginx/app/ /var/www/html/
COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh COPY services/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
COPY services/php/php-fpm-pool.conf /usr/local/etc/php-fpm.d/zz-pleno-workers.conf
COPY services/coolify/api/nginx.conf /etc/nginx/nginx.conf COPY services/coolify/api/nginx.conf /etc/nginx/nginx.conf
COPY services/coolify/api/start.sh /usr/local/bin/coolify-api-start COPY services/coolify/api/start.sh /usr/local/bin/coolify-api-start
RUN set -eux; \ RUN set -eux; \
rm -f /var/www/html/storage/replication-bootstrap.json /var/www/html/storage/replication-bootstrap-*.json; \
sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh /usr/local/bin/coolify-api-start; \ sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh /usr/local/bin/coolify-api-start; \
chmod +x /usr/local/bin/docker-entrypoint.sh /usr/local/bin/coolify-api-start; \ chmod +x /usr/local/bin/docker-entrypoint.sh /usr/local/bin/coolify-api-start; \
COMPOSER_ALLOW_SUPERUSER=1 composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction -d /var/www/html; \ COMPOSER_ALLOW_SUPERUSER=1 composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction -d /var/www/html; \
BIN
View File
Binary file not shown.
+5 -4
View File
@@ -52,9 +52,9 @@ services:
dockerfile: services/edge-broker/Dockerfile dockerfile: services/edge-broker/Dockerfile
container_name: edge-broker container_name: edge-broker
environment: environment:
EDGE_AUTH_MODE: ${EDGE_AUTH_MODE:-manager} EDGE_AUTH_MODE: ${EDGE_AUTH_MODE:-strict}
EDGE_MANAGER_URL: ${EDGE_MANAGER_URL:-http://caddy} EDGE_MANAGER_URL: ${EDGE_MANAGER_URL:-http://caddy}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev} EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.edge-broker-api.rule=Host(`api.example.com`) && PathPrefix(`/edge-broker`)" - "traefik.http.routers.edge-broker-api.rule=Host(`api.example.com`) && PathPrefix(`/edge-broker`)"
@@ -71,6 +71,7 @@ services:
- "traefik.http.middlewares.edge-broker-strip-local.stripPrefix.prefixes=/api/edge-broker" - "traefik.http.middlewares.edge-broker-strip-local.stripPrefix.prefixes=/api/edge-broker"
- "traefik.http.services.edge-broker.loadbalancer.server.port=4300" - "traefik.http.services.edge-broker.loadbalancer.server.port=4300"
caddy: caddy:
image: caddy:2.7.6-alpine image: caddy:2.7.6-alpine
container_name: caddy container_name: caddy
@@ -113,7 +114,7 @@ services:
environment: environment:
AUTO_COMPOSER_INSTALL: "true" AUTO_COMPOSER_INSTALL: "true"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300} EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev} EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
volumes: volumes:
- ./services/nginx/app:/var/www/html - ./services/nginx/app:/var/www/html
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro - ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
@@ -134,7 +135,7 @@ services:
environment: environment:
AUTO_COMPOSER_INSTALL: "false" AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300} EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev} EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
volumes: volumes:
- ./services/nginx/app:/var/www/html - ./services/nginx/app:/var/www/html
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro - ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
+14 -10
View File
@@ -3,6 +3,8 @@ services:
traefik: traefik:
image: traefik:2.11 image: traefik:2.11
container_name: traefik container_name: traefik
group_add:
- "${DOCKER_SOCKET_GID:-65534}"
ports: ports:
- "${TRAEFIK_WEB_PORT:-80}:80" - "${TRAEFIK_WEB_PORT:-80}:80"
- "${TRAEFIK_WEBSECURE_PORT:-443}:443" - "${TRAEFIK_WEBSECURE_PORT:-443}:443"
@@ -101,8 +103,10 @@ services:
mysql-debug: mysql-debug:
image: mysql:8.4 image: mysql:8.4
container_name: mysql-debug container_name: mysql-debug
profiles: [dev]
command: ["mysqld", "--innodb-use-native-aio=0"]
environment: environment:
MYSQL_ROOT_PASSWORD: ${CONFIG_DB_DEBUG_PASSWORD:-debug_root_password} MYSQL_ROOT_PASSWORD: ${CONFIG_DB_DEBUG_PASSWORD:?CONFIG_DB_DEBUG_PASSWORD is required for mysql-debug}
MYSQL_DATABASE: ${CONFIG_DB_DEBUG_DATABASE:-nnks_db_debug} MYSQL_DATABASE: ${CONFIG_DB_DEBUG_DATABASE:-nnks_db_debug}
ports: ports:
- "3307:3306" - "3307:3306"
@@ -121,9 +125,9 @@ services:
dockerfile: services/edge-broker/Dockerfile dockerfile: services/edge-broker/Dockerfile
container_name: edge-broker container_name: edge-broker
environment: environment:
EDGE_AUTH_MODE: ${EDGE_AUTH_MODE:-manager} EDGE_AUTH_MODE: ${EDGE_AUTH_MODE:-strict}
EDGE_MANAGER_URL: ${EDGE_MANAGER_URL:-http://caddy} EDGE_MANAGER_URL: ${EDGE_MANAGER_URL:-http://caddy}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev} EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.edge-broker-api.rule=Host(`api.truckwash.dk`) && PathPrefix(`/edge-broker`)" - "traefik.http.routers.edge-broker-api.rule=Host(`api.truckwash.dk`) && PathPrefix(`/edge-broker`)"
@@ -307,7 +311,7 @@ services:
environment: environment:
AUTO_COMPOSER_INSTALL: "true" AUTO_COMPOSER_INSTALL: "true"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300} EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev} EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
volumes: volumes:
- ./services/nginx/app:/var/www/html - ./services/nginx/app:/var/www/html
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro - ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
@@ -327,7 +331,7 @@ services:
environment: environment:
AUTO_COMPOSER_INSTALL: "false" AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300} EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev} EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
volumes: volumes:
- ./services/nginx/app:/var/www/html - ./services/nginx/app:/var/www/html
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro - ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
@@ -347,7 +351,7 @@ services:
environment: environment:
AUTO_COMPOSER_INSTALL: "false" AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300} EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev} EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
volumes: volumes:
- ./services/nginx/app:/var/www/html - ./services/nginx/app:/var/www/html
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro - ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
@@ -367,7 +371,7 @@ services:
environment: environment:
AUTO_COMPOSER_INSTALL: "false" AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300} EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev} EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
volumes: volumes:
- ./services/nginx/app:/var/www/html - ./services/nginx/app:/var/www/html
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro - ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
@@ -387,7 +391,7 @@ services:
environment: environment:
AUTO_COMPOSER_INSTALL: "false" AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300} EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev} EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
volumes: volumes:
- ./services/nginx/app:/var/www/html - ./services/nginx/app:/var/www/html
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro - ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
@@ -407,7 +411,7 @@ services:
environment: environment:
AUTO_COMPOSER_INSTALL: "false" AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300} EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev} EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
volumes: volumes:
- ./services/nginx/staging:/var/www/html - ./services/nginx/staging:/var/www/html
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro - ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
@@ -427,7 +431,7 @@ services:
environment: environment:
AUTO_COMPOSER_INSTALL: "false" AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300} EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev} EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
volumes: volumes:
- ./services/nginx/app:/var/www/html - ./services/nginx/app:/var/www/html
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro - ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
+105
View File
@@ -3357,6 +3357,9 @@
}, },
"email_notifications_enabled": { "email_notifications_enabled": {
"type": "boolean" "type": "boolean"
},
"superuser_new_customer_email_notifications_enabled": {
"type": "boolean"
} }
} }
} }
@@ -12955,6 +12958,54 @@
} }
} }
}, },
"/slack/config": {
"get": {
"tags": [
"Config"
],
"summary": "Get Slack config",
"operationId": "getSlackConfig",
"responses": {
"200": {
"description": "Slack configuration retrieved successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SlackConfigListResponse"
}
}
}
}
}
},
"post": {
"tags": [
"Config"
],
"summary": "Update Slack config",
"operationId": "updateSlackConfig",
"requestBody": {
"required": false,
"content": {
"application/json": {
"schema": {}
}
}
},
"responses": {
"200": {
"description": "Slack configuration updated successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModuleConfigUpdateResponse"
}
}
}
}
}
}
},
"/backups/config": { "/backups/config": {
"get": { "get": {
"tags": [ "tags": [
@@ -15496,6 +15547,39 @@
"value" "value"
] ]
}, },
"SlackConfigEntry": {
"type": "object",
"properties": {
"module": {
"type": "string",
"enum": [
"Slack"
]
},
"variable": {
"type": "string",
"enum": [
"customer_registration_webhook_url"
]
},
"type": {
"type": "string",
"enum": [
"string"
]
},
"value": {
"type": "string",
"example": "https://hooks.slack.com/services/..."
}
},
"required": [
"module",
"variable",
"type",
"value"
]
},
"BackupsConfigEntry": { "BackupsConfigEntry": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -16240,6 +16324,27 @@
} }
] ]
}, },
"SlackConfigListResponse": {
"allOf": [
{
"$ref": "#/components/schemas/ModuleConfigEnvelopeBase"
},
{
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SlackConfigEntry"
}
}
},
"required": [
"data"
]
}
]
},
"BackupsConfigListResponse": { "BackupsConfigListResponse": {
"allOf": [ "allOf": [
{ {
+3127 -202
View File
File diff suppressed because it is too large Load Diff
+132545
View File
File diff suppressed because one or more lines are too long
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env sh
set -eu
suite="${1:-}"
case "$suite" in
unit|integration|api|legacy|all)
;;
*)
echo "Usage: $0 <unit|integration|api|legacy|all>" >&2
exit 2
;;
esac
script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
repo_root="$(CDPATH= cd -- "$script_dir/.." && pwd)"
cd "$repo_root"
compose_files="-f docker-compose.yml -f .github/docker-compose.ci.yml"
project_suffix="$(date +%s)-$$"
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-php-local-${suite}-${project_suffix}}"
log_dir=".tmp/ci-logs/$suite"
mkdir -p "$log_dir"
env_backup_dir=".tmp/php-ci-env-backup-$project_suffix"
mkdir -p "$env_backup_dir"
had_env=0
had_env_staging=0
if [ -f .env ]; then
cp .env "$env_backup_dir/env"
had_env=1
fi
if [ -f .env.staging ]; then
cp .env.staging "$env_backup_dir/env.staging"
had_env_staging=1
fi
cp .github/ci.env .env
cp .github/ci.env.staging .env.staging
collect_logs() {
status="$1"
if [ "$status" -eq 0 ]; then
return
fi
mkdir -p "$log_dir"
docker compose $compose_files ps > "$log_dir/docker-compose-ps.txt" 2>&1 || true
docker compose $compose_files logs --no-color > "$log_dir/docker-compose.log" 2>&1 || true
docker compose $compose_files cp php1:/var/www/html/build/logs "$log_dir/app-build-logs" >/dev/null 2>&1 || true
docker compose $compose_files cp php1:/var/log/php "$log_dir/php-logs" >/dev/null 2>&1 || true
}
cleanup() {
status="$?"
collect_logs "$status"
docker compose $compose_files down -v >/dev/null 2>&1 || true
if [ "$had_env" -eq 1 ]; then
cp "$env_backup_dir/env" .env
else
rm -f .env
fi
if [ "$had_env_staging" -eq 1 ]; then
cp "$env_backup_dir/env.staging" .env.staging
else
rm -f .env.staging
fi
rm -rf "$env_backup_dir"
exit "$status"
}
trap cleanup EXIT INT TERM
docker compose $compose_files up -d redis mysql-debug php1
docker compose $compose_files exec -T php1 sh -lc '
set -eu
for i in $(seq 1 90); do
if MYSQL_PWD="${CONFIG_DB_PASSWORD:-debug_root_password}" mysqladmin \
-h "${CONFIG_DB_HOST:-mysql-debug}" \
-P "${CONFIG_DB_PORT:-3306}" \
-u "${CONFIG_DB_USER:-root}" \
ping --silent >/dev/null 2>&1; then
exit 0
fi
sleep 1
done
echo "Timed out waiting for mysql-debug" >&2
exit 1
'
tar \
--exclude='./vendor' \
--exclude='./.phpunit.cache' \
--exclude='./build/logs' \
-C services/nginx/app -cf - . \
| docker compose $compose_files exec -T php1 tar -C /var/www/html -xf -
docker compose $compose_files exec -T php1 sh -lc \
'cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress'
docker compose $compose_files exec -T php1 sh -lc \
"cd /var/www/html && composer test:ci:$suite"
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env sh
set -eu
if [ "$#" -eq 0 ]; then
echo "Usage: $0 <service> [service ...]" >&2
exit 2
fi
compose_files="${CI_DOCKER_COMPOSE_FILES:--f docker-compose.yml -f .github/docker-compose.ci.yml}"
lock_file="${CI_DOCKER_LOCK_FILE:-/tmp/pleno-api-ci-docker-compose-up.lock}"
max_attempts="${CI_DOCKER_UP_RETRIES:-${PHP_CI_DOCKER_RETRIES:-3}}"
export COMPOSE_PROFILES="${COMPOSE_PROFILES:-dev}"
compose_up() {
attempt=1
while :; do
docker network prune -f >/dev/null 2>&1 || true
if docker compose $compose_files up -d "$@"; then
return 0
fi
status="$?"
docker compose $compose_files down -v --remove-orphans >/dev/null 2>&1 || true
if [ "$attempt" -ge "$max_attempts" ]; then
return "$status"
fi
sleep_seconds=$((attempt * 5))
echo "Docker compose up failed with status $status; retrying in ${sleep_seconds}s (attempt $((attempt + 1))/$max_attempts)." >&2
sleep "$sleep_seconds"
attempt=$((attempt + 1))
done
}
if command -v flock >/dev/null 2>&1; then
(
flock 9
compose_up "$@"
) 9>"$lock_file"
else
echo "flock is not available; running Docker compose startup without a host lock." >&2
compose_up "$@"
fi
+453
View File
@@ -0,0 +1,453 @@
import { spawn } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import http from "node:http";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const DEFAULT_AGENT_PATH = path.join(
repoRoot,
"services/nginx/app/resources/edge-gateway-agent/agent.php"
);
const DEFAULT_PHP_IMAGE = "php:8.2-cli-bookworm";
const DEFAULT_TIMEOUT_MS = 12000;
function parseArgs(argv = process.argv.slice(2)) {
const options = {
agentPath: DEFAULT_AGENT_PATH,
phpImage: DEFAULT_PHP_IMAGE,
timeoutMs: DEFAULT_TIMEOUT_MS,
keepTemp: false,
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const next = argv[index + 1];
switch (arg) {
case "--agent-path":
options.agentPath = path.resolve(String(next || "").trim());
index += 1;
break;
case "--php-image":
options.phpImage = String(next || "").trim() || DEFAULT_PHP_IMAGE;
index += 1;
break;
case "--timeout-ms":
options.timeoutMs = Number.parseInt(String(next || ""), 10) || DEFAULT_TIMEOUT_MS;
index += 1;
break;
case "--keep-temp":
options.keepTemp = true;
break;
case "--help":
case "-h":
options.help = true;
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
return options;
}
function printUsage() {
process.stdout.write(`Usage:
node scripts/edge-agent-command-drain-proof.mjs [options]
Verifies that a broker-connected PHP compose edge agent still drains API-queued
SET_RELAY_STATE jobs to the LAN worker /relay/switch endpoint.
Options:
--agent-path <path> PHP agent artifact to execute.
Default: ${DEFAULT_AGENT_PATH}
--php-image <image> Docker PHP image with curl, sqlite3, and pdo_sqlite.
Default: ${DEFAULT_PHP_IMAGE}
--timeout-ms <ms> Proof timeout. Default: ${DEFAULT_TIMEOUT_MS}
--keep-temp Keep the temporary config/runtime directory.
--help Show this help text.
`);
}
function readJson(request) {
return new Promise((resolve) => {
let raw = "";
request.setEncoding("utf8");
request.on("data", (chunk) => {
raw += chunk;
});
request.on("end", () => {
if (raw.trim() === "") {
resolve({});
return;
}
try {
resolve(JSON.parse(raw));
} catch {
resolve({ __invalid: raw });
}
});
});
}
function sendJson(response, status, payload) {
const body = JSON.stringify(payload);
response.writeHead(status, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(body),
});
response.end(body);
}
function listen(server) {
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
});
}
function closeServer(server) {
return new Promise((resolve) => {
server.close(() => resolve());
});
}
function websocketAcceptKey(key) {
return crypto
.createHash("sha1")
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
.digest("base64");
}
function createBrokerServer(state) {
const sockets = new Set();
const server = net.createServer((socket) => {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
let buffer = "";
socket.on("data", (chunk) => {
buffer += chunk.toString("binary");
if (state.brokerHandshakeSeen || !buffer.includes("\r\n\r\n")) {
return;
}
const requestText = Buffer.from(buffer, "binary").toString("utf8");
const key = requestText.match(/Sec-WebSocket-Key:\s*(.+)\r\n/i)?.[1]?.trim();
const requestLine = requestText.split("\r\n")[0] || "";
if (!requestLine.includes("/ws/agent?")) {
state.failure = new Error(`unexpected broker path: ${requestLine}`);
}
if (!key) {
state.failure = new Error("broker handshake missing Sec-WebSocket-Key");
return;
}
socket.write([
"HTTP/1.1 101 Switching Protocols",
"Upgrade: websocket",
"Connection: Upgrade",
`Sec-WebSocket-Accept: ${websocketAcceptKey(key)}`,
"",
"",
].join("\r\n"));
state.brokerHandshakeSeen = true;
buffer = "";
});
});
return { server, sockets };
}
function createWorkerServer(state) {
return http.createServer(async (request, response) => {
const url = new URL(request.url, "http://127.0.0.1");
const body = await readJson(request);
state.requests.push({ service: "worker", method: request.method, path: url.pathname, body });
if (request.method === "GET" && url.pathname === "/health") {
sendJson(response, 200, { status: "healthy", timestamp: new Date().toISOString() });
return;
}
if (request.method === "POST" && url.pathname === "/relay/switch") {
state.relaySwitchSeen = true;
if (body.local_ip !== "10.123.0.31" || body.channel !== 0 || body.on !== true) {
state.failure = new Error(`unexpected relay switch payload: ${JSON.stringify(body)}`);
}
sendJson(response, 200, {
online: true,
on: true,
output: true,
raw: { source: "fake-worker" },
});
return;
}
sendJson(response, 404, { message: "not found" });
});
}
function createApiServer(state, brokerPort, workerPort) {
return http.createServer(async (request, response) => {
const url = new URL(request.url, "http://127.0.0.1");
const body = await readJson(request);
state.requests.push({ service: "api", method: request.method, path: url.pathname, body });
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/heartbeat") {
sendJson(response, 200, { data: { ok: true, broker_url: `ws://127.0.0.1:${brokerPort}` } });
return;
}
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/selfserve/machine-signal-bindings") {
sendJson(response, 200, { data: { monitors: [] } });
return;
}
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/poll") {
state.commandPollSeen = true;
if (body.wait_seconds !== 0) {
state.failure = new Error(
`broker-connected command poll should be non-blocking, got wait_seconds=${body.wait_seconds}`
);
}
if (!state.commandDelivered) {
state.commandDelivered = true;
sendJson(response, 200, {
data: {
id: 77,
command_type: "SET_RELAY_STATE",
payload: {
localIp: "10.123.0.31",
channel: 0,
on: true,
relayId: "relay-proof",
},
},
});
return;
}
sendJson(response, 200, { data: null });
return;
}
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/77/result") {
state.resultSeen = true;
if (body.ok !== true || body.result?.on !== true || body.result?.raw?.source !== "fake-worker") {
state.failure = new Error(`unexpected command result: ${JSON.stringify(body)}`);
}
sendJson(response, 200, { data: { acknowledged: true } });
return;
}
sendJson(response, 404, { message: "not found", path: url.pathname, workerPort });
});
}
function writeConfig(tempDir, apiPort, brokerPort, workerPort) {
const containerProofDir = "/proof";
const runtimeDir = `${containerProofDir}/runtime`;
const config = {
apiUrl: `http://127.0.0.1:${apiPort}`,
brokerUrl: `ws://127.0.0.1:${brokerPort}`,
gatewayId: 42,
agentToken: "agent-token",
installDir: containerProofDir,
runtimeDir,
stateDatabasePath: `${runtimeDir}/gateway-state.sqlite`,
workerBaseUrl: `http://127.0.0.1:${workerPort}`,
heartbeatIntervalSeconds: 60,
operationPollTimeoutSeconds: 20,
};
const configPath = path.join(tempDir, "config.json");
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
return { configPath, containerConfigPath: `${containerProofDir}/config.json` };
}
function spawnAgent({ agentPath, phpImage, tempDir, containerConfigPath }) {
return spawn("docker", [
"run",
"--rm",
"--network",
"host",
"-v",
`${agentPath}:/agent.php:ro`,
"-v",
`${tempDir}:/proof`,
phpImage,
"php",
"/agent.php",
"--config",
containerConfigPath,
], { stdio: ["ignore", "pipe", "pipe"] });
}
async function stopChild(child) {
if (child.exitCode !== null || child.signalCode !== null) {
return;
}
child.kill("SIGTERM");
const hardKill = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGKILL");
}
}, 1500);
await Promise.race([
new Promise((resolve) => child.once("exit", resolve)),
new Promise((resolve) => setTimeout(resolve, 2200)),
]);
clearTimeout(hardKill);
}
function evidenceFromState(state, childExited) {
return {
brokerHandshakeSeen: state.brokerHandshakeSeen,
commandPollSeen: state.commandPollSeen,
relaySwitchSeen: state.relaySwitchSeen,
resultSeen: state.resultSeen,
agentStayedRunningUntilProofComplete: !childExited,
};
}
export async function runProof(options) {
if (process.platform !== "linux") {
throw new Error("This proof uses Docker --network host and currently expects Linux.");
}
if (!fs.existsSync(options.agentPath)) {
throw new Error(`Agent artifact not found: ${options.agentPath}`);
}
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "edge-agent-command-drain-proof-"));
fs.mkdirSync(path.join(tempDir, "runtime"), { recursive: true });
const state = {
brokerHandshakeSeen: false,
commandPollSeen: false,
relaySwitchSeen: false,
resultSeen: false,
commandDelivered: false,
failure: null,
requests: [],
};
const broker = createBrokerServer(state);
const workerServer = createWorkerServer(state);
let apiServer = null;
let child = null;
let stdout = "";
let stderr = "";
let childExited = false;
try {
const brokerPort = await listen(broker.server);
const workerPort = await listen(workerServer);
apiServer = createApiServer(state, brokerPort, workerPort);
const apiPort = await listen(apiServer);
const { containerConfigPath } = writeConfig(tempDir, apiPort, brokerPort, workerPort);
child = spawnAgent({ ...options, tempDir, containerConfigPath });
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
child.once("exit", () => {
childExited = true;
});
const deadline = Date.now() + options.timeoutMs;
while (Date.now() < deadline && !state.failure && !childExited) {
if (state.brokerHandshakeSeen && state.commandPollSeen && state.relaySwitchSeen && state.resultSeen) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
const evidence = evidenceFromState(state, childExited);
if (
state.failure ||
!state.brokerHandshakeSeen ||
!state.commandPollSeen ||
!state.relaySwitchSeen ||
!state.resultSeen
) {
const error = state.failure || new Error("missing proof evidence");
error.evidence = evidence;
error.requests = state.requests;
error.stdout = stdout.slice(-3000);
error.stderr = stderr.slice(-3000);
throw error;
}
return {
evidence,
agentPath: options.agentPath,
phpImage: options.phpImage,
tempDir,
requestCount: state.requests.length,
};
} finally {
if (child) {
await stopChild(child);
}
for (const socket of broker.sockets) {
socket.destroy();
}
await Promise.allSettled([
closeServer(broker.server),
closeServer(workerServer),
apiServer ? closeServer(apiServer) : Promise.resolve(),
]);
if (!options.keepTemp) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
}
async function main() {
const options = parseArgs();
if (options.help) {
printUsage();
return;
}
const result = await runProof(options);
process.stdout.write("PASS broker-connected API command poll triggered local relay switch and posted result\n");
process.stdout.write(`${JSON.stringify(result.evidence)}\n`);
process.stdout.write(`Agent: ${result.agentPath}\n`);
process.stdout.write(`PHP image: ${result.phpImage}\n`);
if (options.keepTemp) {
process.stdout.write(`Temp dir: ${result.tempDir}\n`);
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((error) => {
process.stderr.write(`FAIL ${error.message}\n`);
if (error.evidence) {
process.stderr.write(`Evidence: ${JSON.stringify(error.evidence)}\n`);
}
if (error.requests) {
process.stderr.write(`Requests: ${JSON.stringify(error.requests, null, 2)}\n`);
}
if (error.stdout) {
process.stderr.write(`stdout: ${error.stdout}\n`);
}
if (error.stderr) {
process.stderr.write(`stderr: ${error.stderr}\n`);
}
process.exit(1);
});
}
+619
View File
@@ -0,0 +1,619 @@
import { spawn } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import http from "node:http";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const DEFAULT_AGENT_PATH = path.join(
repoRoot,
"services/nginx/app/resources/edge-gateway-agent/agent.php"
);
const DEFAULT_WORKER_PATH = path.join(
repoRoot,
"services/nginx/app/resources/edge-gateway-agent/lan-worker.php"
);
const DEFAULT_PHP_IMAGE = "php:8.2-cli-bookworm";
const DEFAULT_TIMEOUT_MS = 15000;
const AGENT_TOKEN = "agent-token";
function parseArgs(argv = process.argv.slice(2)) {
const options = {
agentPath: DEFAULT_AGENT_PATH,
workerPath: DEFAULT_WORKER_PATH,
phpImage: DEFAULT_PHP_IMAGE,
timeoutMs: DEFAULT_TIMEOUT_MS,
keepTemp: false,
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
const next = argv[index + 1];
switch (arg) {
case "--agent-path":
options.agentPath = path.resolve(String(next || "").trim());
index += 1;
break;
case "--worker-path":
options.workerPath = path.resolve(String(next || "").trim());
index += 1;
break;
case "--php-image":
options.phpImage = String(next || "").trim() || DEFAULT_PHP_IMAGE;
index += 1;
break;
case "--timeout-ms":
options.timeoutMs = Number.parseInt(String(next || ""), 10) || DEFAULT_TIMEOUT_MS;
index += 1;
break;
case "--keep-temp":
options.keepTemp = true;
break;
case "--help":
case "-h":
options.help = true;
break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
return options;
}
function printUsage() {
process.stdout.write(`Usage:
node scripts/edge-agent-to-shelly-proof.mjs [options]
Runs the PHP edge agent and real LAN worker against fake broker, API, and
Shelly RPC endpoints. Verifies that a broker-connected SET_RELAY_STATE command
drains from the API, reaches the worker, triggers a Shelly-style Switch.Set
call, reads Switch.GetStatus, and posts the command result.
Options:
--agent-path <path> PHP agent artifact to execute.
Default: ${DEFAULT_AGENT_PATH}
--worker-path <path> PHP LAN worker artifact to execute.
Default: ${DEFAULT_WORKER_PATH}
--php-image <image> Docker PHP image with curl, sqlite3, and pdo_sqlite.
Default: ${DEFAULT_PHP_IMAGE}
--timeout-ms <ms> Proof timeout. Default: ${DEFAULT_TIMEOUT_MS}
--keep-temp Keep the temporary config/runtime directory.
--help Show this help text.
`);
}
function readJson(request) {
return new Promise((resolve) => {
let raw = "";
request.setEncoding("utf8");
request.on("data", (chunk) => {
raw += chunk;
});
request.on("end", () => {
if (raw.trim() === "") {
resolve({});
return;
}
try {
resolve(JSON.parse(raw));
} catch {
resolve({ __invalid: raw });
}
});
});
}
function sendJson(response, status, payload) {
const body = JSON.stringify(payload);
response.writeHead(status, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(body),
});
response.end(body);
}
function requestJson({ method = "GET", port, path: requestPath, body = null, headers = {} }) {
return new Promise((resolve, reject) => {
const payload = body === null ? null : JSON.stringify(body);
const request = http.request({
hostname: "127.0.0.1",
port,
path: requestPath,
method,
headers: {
accept: "application/json",
...(payload === null ? {} : {
"content-type": "application/json",
"content-length": Buffer.byteLength(payload),
}),
...headers,
},
timeout: 1000,
}, (response) => {
let raw = "";
response.setEncoding("utf8");
response.on("data", (chunk) => {
raw += chunk;
});
response.on("end", () => {
let decoded = {};
try {
decoded = raw.trim() === "" ? {} : JSON.parse(raw);
} catch {
decoded = { __invalid: raw };
}
resolve({ status: response.statusCode || 0, body: decoded });
});
});
request.on("error", reject);
request.on("timeout", () => {
request.destroy(new Error("request timed out"));
});
if (payload !== null) {
request.write(payload);
}
request.end();
});
}
function listen(server) {
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
});
}
function closeServer(server) {
return new Promise((resolve) => {
server.close(() => resolve());
});
}
async function reservePort() {
const server = net.createServer();
const port = await new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
});
await closeServer(server);
return port;
}
function websocketAcceptKey(key) {
return crypto
.createHash("sha1")
.update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
.digest("base64");
}
function createBrokerServer(state) {
const sockets = new Set();
const server = net.createServer((socket) => {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
let buffer = "";
socket.on("data", (chunk) => {
buffer += chunk.toString("binary");
if (state.brokerHandshakeSeen || !buffer.includes("\r\n\r\n")) {
return;
}
const requestText = Buffer.from(buffer, "binary").toString("utf8");
const key = requestText.match(/Sec-WebSocket-Key:\s*(.+)\r\n/i)?.[1]?.trim();
const requestLine = requestText.split("\r\n")[0] || "";
if (!requestLine.includes("/ws/agent?")) {
state.failure = new Error(`unexpected broker path: ${requestLine}`);
}
if (!key) {
state.failure = new Error("broker handshake missing Sec-WebSocket-Key");
return;
}
socket.write([
"HTTP/1.1 101 Switching Protocols",
"Upgrade: websocket",
"Connection: Upgrade",
`Sec-WebSocket-Accept: ${websocketAcceptKey(key)}`,
"",
"",
].join("\r\n"));
state.brokerHandshakeSeen = true;
buffer = "";
});
});
return { server, sockets };
}
function createShellyServer(state) {
return http.createServer((request, response) => {
const url = new URL(request.url, "http://127.0.0.1");
state.requests.push({
service: "shelly",
method: request.method,
path: url.pathname,
query: Object.fromEntries(url.searchParams.entries()),
});
if (request.method === "GET" && url.pathname === "/rpc/Switch.Set") {
state.shellySwitchSetSeen = true;
if (url.searchParams.get("id") !== "0" || url.searchParams.get("on") !== "true") {
state.failure = new Error(`unexpected Shelly Switch.Set query: ${url.search}`);
}
sendJson(response, 200, { was_on: false, output: true });
return;
}
if (request.method === "GET" && url.pathname === "/rpc/Switch.GetStatus") {
state.shellyStatusSeen = true;
if (url.searchParams.get("id") !== "0") {
state.failure = new Error(`unexpected Shelly Switch.GetStatus query: ${url.search}`);
}
sendJson(response, 200, { id: 0, output: true, source: "fake-shelly-rpc" });
return;
}
if (url.pathname.startsWith("/relay/")) {
state.failure = new Error(`legacy Shelly endpoint should not be used for generation 2 proof: ${url.pathname}`);
}
sendJson(response, 404, { message: "not found" });
});
}
function createApiServer(state, brokerPort, shellyAddress) {
return http.createServer(async (request, response) => {
const url = new URL(request.url, "http://127.0.0.1");
const body = await readJson(request);
state.requests.push({ service: "api", method: request.method, path: url.pathname, body });
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/heartbeat") {
sendJson(response, 200, { data: { ok: true, broker_url: `ws://127.0.0.1:${brokerPort}` } });
return;
}
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/selfserve/machine-signal-bindings") {
sendJson(response, 200, { data: { monitors: [] } });
return;
}
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/poll") {
state.commandPollSeen = true;
if (body.wait_seconds !== 0) {
state.failure = new Error(
`broker-connected command poll should be non-blocking, got wait_seconds=${body.wait_seconds}`
);
}
if (!state.commandDelivered) {
state.commandDelivered = true;
sendJson(response, 200, {
data: {
id: 77,
command_type: "SET_RELAY_STATE",
payload: {
localIp: shellyAddress,
local_ip: shellyAddress,
channel: 0,
on: true,
relayId: "relay-proof",
relay_id: "relay-proof",
deviceGeneration: 2,
device_generation: 2,
},
},
});
return;
}
sendJson(response, 200, { data: null });
return;
}
if (request.method === "POST" && url.pathname === "/edge-agent/gateways/42/commands/77/result") {
state.resultSeen = true;
if (
body.ok !== true ||
body.result?.on !== true ||
body.result?.output !== true ||
body.result?.raw?.source !== "fake-shelly-rpc"
) {
state.failure = new Error(`unexpected command result: ${JSON.stringify(body)}`);
}
sendJson(response, 200, { data: { acknowledged: true } });
return;
}
sendJson(response, 404, { message: "not found", path: url.pathname });
});
}
function writeConfig(tempDir, apiPort, brokerPort, workerPort) {
const containerProofDir = "/proof";
const runtimeDir = `${containerProofDir}/runtime`;
const config = {
apiUrl: `http://127.0.0.1:${apiPort}`,
brokerUrl: `ws://127.0.0.1:${brokerPort}`,
gatewayId: 42,
agentToken: AGENT_TOKEN,
installDir: containerProofDir,
runtimeDir,
stateDatabasePath: `${runtimeDir}/gateway-state.sqlite`,
workerBaseUrl: `http://127.0.0.1:${workerPort}`,
heartbeatIntervalSeconds: 60,
operationPollTimeoutSeconds: 20,
};
const configPath = path.join(tempDir, "config.json");
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
return { containerConfigPath: `${containerProofDir}/config.json` };
}
function spawnWorker({ workerPath, phpImage, workerPort }) {
return spawn("docker", [
"run",
"--rm",
"--network",
"host",
"-e",
`TRUCKWASH_WORKER_TOKEN=${AGENT_TOKEN}`,
"-v",
`${workerPath}:/lan-worker.php:ro`,
phpImage,
"php",
"-S",
`127.0.0.1:${workerPort}`,
"/lan-worker.php",
], { stdio: ["ignore", "pipe", "pipe"] });
}
function spawnAgent({ agentPath, phpImage, tempDir, containerConfigPath }) {
return spawn("docker", [
"run",
"--rm",
"--network",
"host",
"-v",
`${agentPath}:/agent.php:ro`,
"-v",
`${tempDir}:/proof`,
phpImage,
"php",
"/agent.php",
"--config",
containerConfigPath,
], { stdio: ["ignore", "pipe", "pipe"] });
}
async function waitForWorker(workerPort, child, timeoutMs) {
const deadline = Date.now() + timeoutMs;
let lastError = null;
while (Date.now() < deadline) {
if (child.exitCode !== null || child.signalCode !== null) {
throw new Error(`LAN worker exited before becoming healthy: ${child.exitCode ?? child.signalCode}`);
}
try {
const response = await requestJson({ port: workerPort, path: "/health" });
if (response.status === 200 && response.body?.service === "lan-worker") {
return;
}
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw lastError || new Error("LAN worker did not become healthy");
}
async function stopChild(child) {
if (child.exitCode !== null || child.signalCode !== null) {
return;
}
child.kill("SIGTERM");
const hardKill = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGKILL");
}
}, 1500);
await Promise.race([
new Promise((resolve) => child.once("exit", resolve)),
new Promise((resolve) => setTimeout(resolve, 2200)),
]);
clearTimeout(hardKill);
}
function evidenceFromState(state, agentExited, workerExited) {
return {
brokerHandshakeSeen: state.brokerHandshakeSeen,
commandPollSeen: state.commandPollSeen,
shellySwitchSetSeen: state.shellySwitchSetSeen,
shellyStatusSeen: state.shellyStatusSeen,
resultSeen: state.resultSeen,
agentStayedRunningUntilProofComplete: !agentExited,
workerStayedRunningUntilProofComplete: !workerExited,
};
}
export async function runProof(options) {
if (process.platform !== "linux") {
throw new Error("This proof uses Docker --network host and currently expects Linux.");
}
if (!fs.existsSync(options.agentPath)) {
throw new Error(`Agent artifact not found: ${options.agentPath}`);
}
if (!fs.existsSync(options.workerPath)) {
throw new Error(`LAN worker artifact not found: ${options.workerPath}`);
}
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "edge-agent-to-shelly-proof-"));
fs.mkdirSync(path.join(tempDir, "runtime"), { recursive: true });
const state = {
brokerHandshakeSeen: false,
commandPollSeen: false,
shellySwitchSetSeen: false,
shellyStatusSeen: false,
resultSeen: false,
commandDelivered: false,
failure: null,
requests: [],
};
const broker = createBrokerServer(state);
const shellyServer = createShellyServer(state);
let apiServer = null;
let agent = null;
let worker = null;
let agentStdout = "";
let agentStderr = "";
let workerStdout = "";
let workerStderr = "";
let agentExited = false;
let workerExited = false;
try {
const brokerPort = await listen(broker.server);
const shellyPort = await listen(shellyServer);
const workerPort = await reservePort();
const shellyAddress = `127.0.0.1:${shellyPort}`;
apiServer = createApiServer(state, brokerPort, shellyAddress);
const apiPort = await listen(apiServer);
const { containerConfigPath } = writeConfig(tempDir, apiPort, brokerPort, workerPort);
worker = spawnWorker({ ...options, workerPort });
worker.stdout.on("data", (chunk) => {
workerStdout += chunk.toString();
});
worker.stderr.on("data", (chunk) => {
workerStderr += chunk.toString();
});
worker.once("exit", () => {
workerExited = true;
});
await waitForWorker(workerPort, worker, 5000);
agent = spawnAgent({ ...options, tempDir, containerConfigPath });
agent.stdout.on("data", (chunk) => {
agentStdout += chunk.toString();
});
agent.stderr.on("data", (chunk) => {
agentStderr += chunk.toString();
});
agent.once("exit", () => {
agentExited = true;
});
const deadline = Date.now() + options.timeoutMs;
while (Date.now() < deadline && !state.failure && !agentExited && !workerExited) {
if (
state.brokerHandshakeSeen &&
state.commandPollSeen &&
state.shellySwitchSetSeen &&
state.shellyStatusSeen &&
state.resultSeen
) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
const evidence = evidenceFromState(state, agentExited, workerExited);
if (
state.failure ||
!state.brokerHandshakeSeen ||
!state.commandPollSeen ||
!state.shellySwitchSetSeen ||
!state.shellyStatusSeen ||
!state.resultSeen
) {
const error = state.failure || new Error("missing proof evidence");
error.evidence = evidence;
error.requests = state.requests;
error.agentStdout = agentStdout.slice(-3000);
error.agentStderr = agentStderr.slice(-3000);
error.workerStdout = workerStdout.slice(-3000);
error.workerStderr = workerStderr.slice(-3000);
throw error;
}
return {
evidence,
agentPath: options.agentPath,
workerPath: options.workerPath,
phpImage: options.phpImage,
tempDir,
requestCount: state.requests.length,
};
} finally {
if (agent) {
await stopChild(agent);
}
if (worker) {
await stopChild(worker);
}
for (const socket of broker.sockets) {
socket.destroy();
}
await Promise.allSettled([
closeServer(broker.server),
closeServer(shellyServer),
apiServer ? closeServer(apiServer) : Promise.resolve(),
]);
if (!options.keepTemp) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
}
async function main() {
const options = parseArgs();
if (options.help) {
printUsage();
return;
}
const result = await runProof(options);
process.stdout.write("PASS broker-connected API command triggered real LAN worker Shelly RPC signal and posted result\n");
process.stdout.write(`${JSON.stringify(result.evidence)}\n`);
process.stdout.write(`Agent: ${result.agentPath}\n`);
process.stdout.write(`LAN worker: ${result.workerPath}\n`);
process.stdout.write(`PHP image: ${result.phpImage}\n`);
if (options.keepTemp) {
process.stdout.write(`Temp dir: ${result.tempDir}\n`);
}
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((error) => {
process.stderr.write(`FAIL ${error.message}\n`);
if (error.evidence) {
process.stderr.write(`Evidence: ${JSON.stringify(error.evidence)}\n`);
}
if (error.requests) {
process.stderr.write(`Requests: ${JSON.stringify(error.requests, null, 2)}\n`);
}
if (error.agentStdout) {
process.stderr.write(`agent stdout: ${error.agentStdout}\n`);
}
if (error.agentStderr) {
process.stderr.write(`agent stderr: ${error.agentStderr}\n`);
}
if (error.workerStdout) {
process.stderr.write(`worker stdout: ${error.workerStdout}\n`);
}
if (error.workerStderr) {
process.stderr.write(`worker stderr: ${error.workerStderr}\n`);
}
process.exit(1);
});
}
+120 -41
View File
@@ -10,7 +10,7 @@ import { promisify } from "node:util";
import { DEFAULT_CONFIG_FILE_NAME, DEFAULT_HOST_API_URL } from "./test-gateway.mjs"; import { DEFAULT_CONFIG_FILE_NAME, DEFAULT_HOST_API_URL } from "./test-gateway.mjs";
const execFile = promisify(execFileCallback); const execFile = promisify(execFileCallback);
const COMPOSE_SERVICES = ["traefik", "redis", "mysql-debug", "edge-broker", "php1", "caddy"]; const COMPOSE_SERVICES = ["traefik", "redis", "mysql-debug", "edge-broker", "php1", "php2", "php3", "php4", "php5", "caddy"];
function composeArgs(projectName, args) { function composeArgs(projectName, args) {
return ["compose", "-p", projectName, ...args]; return ["compose", "-p", projectName, ...args];
@@ -94,9 +94,23 @@ function directCaddyBaseUrl(baseUrl) {
return normalizeBaseUrl(url.toString()); return normalizeBaseUrl(url.toString());
} }
function isLocalHost(hostname) {
const normalized = String(hostname || "").toLowerCase().replace(/^\[|\]$/g, "");
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
}
function resolveBrokerWebSocketUrl(rawUrl, apiBaseUrl) { function resolveBrokerWebSocketUrl(rawUrl, apiBaseUrl) {
const websocketUrl = new URL(String(rawUrl)); const websocketUrl = new URL(String(rawUrl));
const apiUrl = new URL(normalizeBaseUrl(apiBaseUrl)); const apiUrl = new URL(normalizeBaseUrl(apiBaseUrl));
const ciBrokerPort = String(process.env.EDGE_BROKER_CI_PORT || "").trim();
if (isLocalHost(apiUrl.hostname) && websocketUrl.hostname === "edge-broker" && ciBrokerPort !== "") {
websocketUrl.protocol = apiUrl.protocol === "https:" ? "wss:" : "ws:";
websocketUrl.hostname = apiUrl.hostname;
websocketUrl.port = ciBrokerPort;
websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
return websocketUrl.toString();
}
if (apiUrl.hostname === "caddy" && websocketUrl.hostname === "caddy") { if (apiUrl.hostname === "caddy" && websocketUrl.hostname === "caddy") {
websocketUrl.hostname = "edge-broker"; websocketUrl.hostname = "edge-broker";
@@ -104,6 +118,18 @@ function resolveBrokerWebSocketUrl(rawUrl, apiBaseUrl) {
websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/"; websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
} }
if (isLocalHost(apiUrl.hostname) && ["caddy", "edge-broker"].includes(websocketUrl.hostname)) {
const brokerPath = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
websocketUrl.protocol = apiUrl.protocol === "https:" ? "wss:" : "ws:";
websocketUrl.hostname = apiUrl.hostname;
websocketUrl.port = apiUrl.port;
websocketUrl.pathname = `/api/edge-broker${brokerPath}`;
}
if (isLocalHost(websocketUrl.hostname)) {
websocketUrl.pathname = websocketUrl.pathname.replace(/^\/edge-broker(?=\/|$)/, "") || "/";
}
return websocketUrl.toString(); return websocketUrl.toString();
} }
@@ -514,6 +540,10 @@ function shouldCopyGatewayConfig() {
return /^(1|true|yes)$/i.test(String(process.env.EDGE_GATEWAY_E2E_COPY_CONFIG || "").trim()); return /^(1|true|yes)$/i.test(String(process.env.EDGE_GATEWAY_E2E_COPY_CONFIG || "").trim());
} }
function shouldSkipComposeUp() {
return /^(1|true|yes)$/i.test(String(process.env.EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP || "").trim());
}
function collectMessages(rows) { function collectMessages(rows) {
return Array.isArray(rows) return Array.isArray(rows)
? rows ? rows
@@ -551,6 +581,38 @@ function summarizeStreamMessages(messages, limit = 12) {
.filter(Boolean); .filter(Boolean);
} }
async function readGatewayDiagnostics({ baseUrl, authToken, gatewayId, containerName }) {
const diagnostics = {};
if (gatewayId !== null && gatewayId > 0 && authToken) {
try {
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
token: authToken,
});
diagnostics.gateway = {
status: detail?.data?.status ?? null,
channelStatus: detail?.data?.channel_status ?? null,
brokerPresence: detail?.data?.metadata?.broker_presence ?? null,
brokerConnected: detail?.data?.metadata?.broker_connected ?? null,
brokerLastError: detail?.data?.metadata?.broker_last_error ?? null,
};
} catch (error) {
diagnostics.gatewayError = error instanceof Error ? error.message : String(error);
}
}
try {
const logs = await runCommand("docker", ["logs", "--tail", "120", containerName], {
allowFailure: true,
});
diagnostics.containerLogs = String(`${logs.stdout || ""}${logs.stderr || ""}`).trim().split(/\r?\n/).slice(-120);
} catch (error) {
diagnostics.containerLogError = error instanceof Error ? error.message : String(error);
}
return diagnostics;
}
async function main() { async function main() {
const scriptPath = fileURLToPath(import.meta.url); const scriptPath = fileURLToPath(import.meta.url);
const rootDir = await resolveRootDir(scriptPath); const rootDir = await resolveRootDir(scriptPath);
@@ -570,7 +632,9 @@ async function main() {
let runnerNetworkAttached = false; let runnerNetworkAttached = false;
try { try {
await ensureComposeServices(rootDir, composeProject); if (!shouldSkipComposeUp()) {
await ensureComposeServices(rootDir, composeProject);
}
runnerNetworkAttached = await connectCurrentContainerToComposeNetwork(rootDir, composeProject); runnerNetworkAttached = await connectCurrentContainerToComposeNetwork(rootDir, composeProject);
baseUrl = await waitForApiReady(baseUrl, rootDir, composeProject, runnerNetworkAttached); baseUrl = await waitForApiReady(baseUrl, rootDir, composeProject, runnerNetworkAttached);
process.stdout.write(`Using API base URL ${baseUrl}\n`); process.stdout.write(`Using API base URL ${baseUrl}\n`);
@@ -663,22 +727,35 @@ async function main() {
} }
); );
await waitForCondition( try {
async () => { await waitForCondition(
const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, { async () => {
token: authToken, const detail = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}`, {
}); token: authToken,
});
return Boolean( return Boolean(
detail?.data?.channel_status?.broker?.connected detail?.data?.channel_status?.broker?.connected
|| detail?.data?.metadata?.broker_connected || detail?.data?.metadata?.broker_connected
); );
}, },
{ {
timeoutMs: 90_000, timeoutMs: 90_000,
message: "Gateway never established a live broker connection after install.", message: "Gateway never established a live broker connection after install.",
} }
); );
} catch (error) {
const diagnostics = await readGatewayDiagnostics({
baseUrl,
authToken,
gatewayId,
containerName,
});
throw new Error([
error instanceof Error ? error.message : String(error),
`Broker diagnostics: ${JSON.stringify(diagnostics, null, 2)}`,
].join("\n"));
}
const WebSocketImpl = await loadWebSocketImplementation(); const WebSocketImpl = await loadWebSocketImplementation();
const streamSession = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/stream-session`, { const streamSession = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/stream-session`, {
@@ -701,11 +778,14 @@ async function main() {
{ timeoutMs: 15_000, message: "Gateway stream never became ready." } { timeoutMs: 15_000, message: "Gateway stream never became ready." }
); );
const readyMessage = streamMessages.find((message) => message?.type === "gateway.stream.ready"); await waitForSocketMessage(
assert.equal( streamMessages,
Boolean(readyMessage?.connected), (message) => (
true, message?.type === "gateway.stream.ready" && message?.connected === true
"Gateway stream became ready before the broker reported the gateway as connected." ) || (
message?.type === "presence.changed" && message?.status === "connected"
),
{ timeoutMs: 45_000, message: "Gateway stream never observed a connected broker presence." }
); );
const operationResponse = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/operations`, { const operationResponse = await apiRequest(baseUrl, "POST", `/edge-gateways/${gatewayId}/operations`, {
@@ -733,10 +813,24 @@ async function main() {
assert.ok(operationId > 0, "Operation creation did not return an operation id."); assert.ok(operationId > 0, "Operation creation did not return an operation id.");
try { try {
await waitForSocketMessage( await waitForCondition(
streamMessages, async () => {
(message) => message?.type === "task.updated" && Number(message?.operationId || 0) === operationId, const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, {
{ timeoutMs: 180_000, message: "Live gateway stream never emitted task.updated for the queued operation." } token: authToken,
});
const operation = Array.isArray(operations?.data)
? operations.data.find((item) => Number(item?.id || 0) === operationId)
: null;
return operation?.status === "COMPLETED"
|| streamMessages.some((message) => (
message?.type === "task.updated"
&& Number(message?.operationId || 0) === operationId
&& message?.operation?.status === "COMPLETED"
));
},
{ timeoutMs: 180_000, message: "Gateway operation never completed through the live agent." }
); );
} catch (error) { } catch (error) {
let operationSnapshot = null; let operationSnapshot = null;
@@ -759,21 +853,6 @@ async function main() {
throw new Error(diagnostic); throw new Error(diagnostic);
} }
await waitForCondition(
async () => {
const operations = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/operations`, {
token: authToken,
});
const operation = Array.isArray(operations?.data)
? operations.data.find((item) => Number(item?.id || 0) === operationId)
: null;
return operation?.status === "COMPLETED";
},
{ timeoutMs: 180_000, message: "Gateway operation never completed through the live agent." }
);
await waitForSocketMessage( await waitForSocketMessage(
streamMessages, streamMessages,
(message) => message?.type === "gateway.telemetry" || message?.type === "stats.updated", (message) => message?.type === "gateway.telemetry" || message?.type === "stats.updated",
+60 -6
View File
@@ -18,6 +18,7 @@ cd "$repo_root"
compose_files="-f docker-compose.yml -f .github/docker-compose.ci.yml" compose_files="-f docker-compose.yml -f .github/docker-compose.ci.yml"
project_suffix="$(date +%s)-$$" project_suffix="$(date +%s)-$$"
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-php-local-${suite}-${project_suffix}}" export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-php-local-${suite}-${project_suffix}}"
export COMPOSE_PROFILES="${COMPOSE_PROFILES:-dev}"
log_dir=".tmp/ci-logs/$suite" log_dir=".tmp/ci-logs/$suite"
mkdir -p "$log_dir" mkdir -p "$log_dir"
@@ -51,6 +52,50 @@ collect_logs() {
docker compose $compose_files cp php1:/var/log/php "$log_dir/php-logs" >/dev/null 2>&1 || true docker compose $compose_files cp php1:/var/log/php "$log_dir/php-logs" >/dev/null 2>&1 || true
} }
retry_command() {
max_attempts="$1"
shift
attempt=1
while :; do
"$@" && return 0
status="$?"
if [ "$attempt" -ge "$max_attempts" ]; then
return "$status"
fi
sleep_seconds=$((attempt * 5))
echo "Command failed with status $status; retrying in ${sleep_seconds}s (attempt $((attempt + 1))/$max_attempts): $*" >&2
sleep "$sleep_seconds"
attempt=$((attempt + 1))
done
}
composer_install() {
dist_attempts="${PHP_CI_COMPOSER_RETRIES:-3}"
source_attempts="${PHP_CI_COMPOSER_SOURCE_RETRIES:-2}"
if retry_command "$dist_attempts" \
docker compose $compose_files exec -T php1 sh -lc \
'cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress'; then
return 0
fi
echo "Composer dist install failed after ${dist_attempts} attempts; retrying with --prefer-source." >&2
retry_command "$source_attempts" \
docker compose $compose_files exec -T php1 sh -lc \
'cd /var/www/html && composer install --no-interaction --prefer-source --no-progress'
}
configure_ci_docker_subnet() {
if [ -n "${CI_DOCKER_SUBNET:-}" ]; then
return
fi
checksum="$(printf '%s' "$COMPOSE_PROJECT_NAME" | cksum | awk '{print $1}')"
subnet_second=$((64 + ((checksum / 256) % 64)))
subnet_third=$((checksum % 256))
export CI_DOCKER_SUBNET="10.${subnet_second}.${subnet_third}.0/24"
}
cleanup() { cleanup() {
status="$?" status="$?"
collect_logs "$status" collect_logs "$status"
@@ -70,7 +115,8 @@ cleanup() {
} }
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
docker compose $compose_files up -d redis mysql-debug php1 configure_ci_docker_subnet
sh scripts/ci-docker-compose-up.sh redis mysql-debug php1
docker compose $compose_files exec -T php1 sh -lc ' docker compose $compose_files exec -T php1 sh -lc '
set -eu set -eu
@@ -93,10 +139,18 @@ tar \
--exclude='./.phpunit.cache' \ --exclude='./.phpunit.cache' \
--exclude='./build/logs' \ --exclude='./build/logs' \
-C services/nginx/app -cf - . \ -C services/nginx/app -cf - . \
| docker compose $compose_files exec -T php1 tar -C /var/www/html -xf - | docker compose $compose_files exec -T php1 tar --no-same-owner -C /var/www/html -xf -
docker compose $compose_files exec -T php1 sh -lc 'rm -rf /var/www/repo-root && mkdir -p /var/www/repo-root'
tar \
-cf - \
Dockerfile \
Dockerfile.coolify-api \
services/php/Dockerfile \
services/php/php-fpm-pool.conf \
| docker compose $compose_files exec -T php1 tar --no-same-owner -C /var/www/repo-root -xf -
composer_install
docker compose $compose_files exec -T php1 sh -lc \ docker compose $compose_files exec -T php1 sh -lc \
'cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress' "cd /var/www/html && PLENO_REPO_ROOT_FOR_TESTS=/var/www/repo-root composer test:ci:$suite"
docker compose $compose_files exec -T php1 sh -lc \
"cd /var/www/html && composer test:ci:$suite"
+73 -3
View File
@@ -1,14 +1,32 @@
import process from "node:process"; import process from "node:process";
import path from "node:path"; import path from "node:path";
import { createHash } from "node:crypto";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
export const DEFAULT_STAGING_BASE_URL = "https://api.truckwash.io:4433"; export const DEFAULT_STAGING_BASE_URL = "https://api.truckwash.io:4433";
export const EXPECTED_INSTALL_VERSION = "compose-php-agent-v3";
export const REQUIRED_MANIFEST_ARTIFACTS = [
"agent.php",
"lan-worker.php",
"auto-updater.php",
"docker-compose.gateway.yml",
"Dockerfile.edge-agent",
"Dockerfile.lan-worker",
"Dockerfile.auto-updater",
"gateway-launcher.sh",
"truckwash-edge-gateway-stack.service",
"truckwash-edge-agent.service",
];
export const INSTALLER_SCRIPT_REQUIRED_SNIPPETS = [ export const INSTALLER_SCRIPT_REQUIRED_SNIPPETS = [
"/edge-agent/install-token/status", "/edge-agent/install-token/status",
"/edge-agent/artifacts/manifest.json",
"report_install_status", "report_install_status",
'begin_install_phase "VERIFY_TOKEN"', 'begin_install_phase "VERIFY_TOKEN"',
'begin_install_phase "VERIFY_ARTIFACTS"',
'begin_install_phase "WAIT_FOR_CLAIM"', 'begin_install_phase "WAIT_FOR_CLAIM"',
'report_install_status "FAILED"', 'report_install_status "FAILED"',
"verify_manifest_artifact",
EXPECTED_INSTALL_VERSION,
]; ];
export function normalizeBaseUrl(url) { export function normalizeBaseUrl(url) {
@@ -55,13 +73,20 @@ export function buildChecks(baseUrl, installToken) {
name: "Ping", name: "Ping",
url: `${normalizedBaseUrl}/ping`, url: `${normalizedBaseUrl}/ping`,
}, },
{
name: "Artifact manifest",
url: `${normalizedBaseUrl}/edge-agent/artifacts/manifest.json`,
artifactName: "manifest.json",
},
{ {
name: "Agent PHP artifact", name: "Agent PHP artifact",
url: `${normalizedBaseUrl}/edge-agent/artifacts/agent.php`, url: `${normalizedBaseUrl}/edge-agent/artifacts/agent.php`,
artifactName: "agent.php",
}, },
{ {
name: "Service unit artifact", name: "Service unit artifact",
url: `${normalizedBaseUrl}/edge-agent/artifacts/truckwash-edge-agent.service`, url: `${normalizedBaseUrl}/edge-agent/artifacts/truckwash-edge-agent.service`,
artifactName: "truckwash-edge-agent.service",
}, },
{ {
name: "Installer script", name: "Installer script",
@@ -70,6 +95,42 @@ export function buildChecks(baseUrl, installToken) {
]; ];
} }
export function validateArtifactManifestBody(body) {
const manifest = JSON.parse(String(body || ""));
if (manifest.version !== EXPECTED_INSTALL_VERSION) {
throw new Error(`Artifact manifest version mismatch: expected ${EXPECTED_INSTALL_VERSION}, got ${manifest.version}`);
}
if (!Array.isArray(manifest.artifacts)) {
throw new Error("Artifact manifest is missing artifacts.");
}
const byName = new Map(manifest.artifacts.map((artifact) => [artifact?.name, artifact]));
const missingArtifacts = REQUIRED_MANIFEST_ARTIFACTS.filter((artifactName) => !byName.has(artifactName));
if (missingArtifacts.length) {
throw new Error(`Artifact manifest is missing required artifacts: ${missingArtifacts.join(", ")}`);
}
return manifest;
}
export function validateArtifactBodyAgainstManifest(manifest, artifactName, body) {
const artifact = manifest?.artifacts?.find((entry) => entry?.name === artifactName);
if (!artifact) {
throw new Error(`Artifact ${artifactName} is missing from manifest.`);
}
const buffer = Buffer.isBuffer(body) ? body : Buffer.from(String(body || ""));
const sha256 = createHash("sha256").update(buffer).digest("hex");
if (sha256 !== artifact.sha256) {
throw new Error(`Artifact ${artifactName} hash mismatch: ${sha256} !== ${artifact.sha256}`);
}
if (buffer.length !== artifact.bytes) {
throw new Error(`Artifact ${artifactName} size mismatch: ${buffer.length} !== ${artifact.bytes}`);
}
return artifact;
}
export function validateInstallerScriptBody(body) { export function validateInstallerScriptBody(body) {
const source = String(body || ""); const source = String(body || "");
const missingSnippets = INSTALLER_SCRIPT_REQUIRED_SNIPPETS.filter((snippet) => !source.includes(snippet)); const missingSnippets = INSTALLER_SCRIPT_REQUIRED_SNIPPETS.filter((snippet) => !source.includes(snippet));
@@ -107,16 +168,17 @@ export async function runSmoke({ baseUrl, installToken }) {
const checks = buildChecks(baseUrl, installToken); const checks = buildChecks(baseUrl, installToken);
const results = []; const results = [];
let artifactManifest = null;
for (const check of checks) { for (const check of checks) {
process.stdout.write(`[staging-smoke] GET ${check.url}\n`); process.stdout.write(`[staging-smoke] GET ${check.url}\n`);
const response = await fetch(check.url); const response = await fetch(check.url);
const body = await response.text(); const body = Buffer.from(await response.arrayBuffer());
const result = { const result = {
...check, ...check,
status: response.status, status: response.status,
ok: response.ok, ok: response.ok,
bodyPreview: previewBody(body), bodyPreview: previewBody(body.toString("utf8")),
}; };
results.push(result); results.push(result);
@@ -127,8 +189,16 @@ export async function runSmoke({ baseUrl, installToken }) {
); );
} }
if (check.name === "Artifact manifest") {
artifactManifest = validateArtifactManifestBody(body.toString("utf8"));
result.version = artifactManifest.version;
result.artifactCount = artifactManifest.artifacts.length;
}
if (artifactManifest && check.artifactName && check.artifactName !== "manifest.json") {
result.verifiedArtifact = validateArtifactBodyAgainstManifest(artifactManifest, check.artifactName, body);
}
if (check.name === "Installer script") { if (check.name === "Installer script") {
result.verifiedSnippets = validateInstallerScriptBody(body); result.verifiedSnippets = validateInstallerScriptBody(body.toString("utf8"));
} }
} }
@@ -3,10 +3,13 @@ import assert from "node:assert/strict";
import { import {
DEFAULT_STAGING_BASE_URL, DEFAULT_STAGING_BASE_URL,
EXPECTED_INSTALL_VERSION,
INSTALLER_SCRIPT_REQUIRED_SNIPPETS, INSTALLER_SCRIPT_REQUIRED_SNIPPETS,
buildChecks, buildChecks,
normalizeBaseUrl, normalizeBaseUrl,
parseArgs, parseArgs,
validateArtifactBodyAgainstManifest,
validateArtifactManifestBody,
validateInstallerScriptBody, validateInstallerScriptBody,
} from "./staging-edge-gateway-smoke.mjs"; } from "./staging-edge-gateway-smoke.mjs";
@@ -32,18 +35,58 @@ test("buildChecks targets the public staging endpoints", () => {
assert.deepEqual(checks.map((check) => check.url), [ assert.deepEqual(checks.map((check) => check.url), [
"https://api.truckwash.io:4433/ping", "https://api.truckwash.io:4433/ping",
"https://api.truckwash.io:4433/edge-agent/artifacts/manifest.json",
"https://api.truckwash.io:4433/edge-agent/artifacts/agent.php", "https://api.truckwash.io:4433/edge-agent/artifacts/agent.php",
"https://api.truckwash.io:4433/edge-agent/artifacts/truckwash-edge-agent.service", "https://api.truckwash.io:4433/edge-agent/artifacts/truckwash-edge-agent.service",
"https://api.truckwash.io:4433/edge-agent/install.sh?token=abc%20123", "https://api.truckwash.io:4433/edge-agent/install.sh?token=abc%20123",
]); ]);
}); });
test("validateArtifactManifestBody requires v3 install artifacts", () => {
const artifacts = [
"agent.php",
"lan-worker.php",
"auto-updater.php",
"docker-compose.gateway.yml",
"Dockerfile.edge-agent",
"Dockerfile.lan-worker",
"Dockerfile.auto-updater",
"gateway-launcher.sh",
"truckwash-edge-gateway-stack.service",
"truckwash-edge-agent.service",
].map((name) => ({ name, sha256: "abc", bytes: 1 }));
const manifest = validateArtifactManifestBody(JSON.stringify({
version: EXPECTED_INSTALL_VERSION,
artifacts,
}));
assert.equal(manifest.version, EXPECTED_INSTALL_VERSION);
});
test("validateArtifactBodyAgainstManifest verifies size and hash", () => {
const body = Buffer.from("hello");
const manifest = {
artifacts: [{
name: "agent.php",
sha256: "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
bytes: body.length,
}],
};
assert.equal(validateArtifactBodyAgainstManifest(manifest, "agent.php", body).name, "agent.php");
});
test("validateInstallerScriptBody requires install-session reporting wiring", () => { test("validateInstallerScriptBody requires install-session reporting wiring", () => {
const script = ` const script = `
INSTALL_STATUS_URL="https://api.truckwash.io:4433/edge-agent/install-token/status" INSTALL_STATUS_URL="https://api.truckwash.io:4433/edge-agent/install-token/status"
fetch_http "Download artifact manifest" "https://api.truckwash.io:4433/edge-agent/artifacts/manifest.json"
report_install_status "FAILED" report_install_status "FAILED"
begin_install_phase "VERIFY_TOKEN" "Verifying install token" begin_install_phase "VERIFY_TOKEN" "Verifying install token"
begin_install_phase "VERIFY_ARTIFACTS" "Verifying edge gateway artifacts"
begin_install_phase "WAIT_FOR_CLAIM" "Waiting for gateway heartbeat and claim" begin_install_phase "WAIT_FOR_CLAIM" "Waiting for gateway heartbeat and claim"
verify_manifest_artifact
${EXPECTED_INSTALL_VERSION}
`; `;
assert.deepEqual(validateInstallerScriptBody(script), INSTALLER_SCRIPT_REQUIRED_SNIPPETS); assert.deepEqual(validateInstallerScriptBody(script), INSTALLER_SCRIPT_REQUIRED_SNIPPETS);
+30 -12
View File
@@ -12,7 +12,7 @@ export const DEFAULT_IMAGE_TAG = "truckwash-edge-agent:test-gateway";
export const DEFAULT_CONFIG_FILE_NAME = "test-gateway.json"; export const DEFAULT_CONFIG_FILE_NAME = "test-gateway.json";
export const DEFAULT_HOST_API_URL = "http://localhost/api"; export const DEFAULT_HOST_API_URL = "http://localhost/api";
export const DEFAULT_CONTAINER_API_URL = "http://caddy"; export const DEFAULT_CONTAINER_API_URL = "http://caddy";
export const DEFAULT_CONTAINER_BROKER_URL = "http://edge-broker:4300"; export const DEFAULT_CONTAINER_BROKER_URL = "ws://edge-broker:4300";
export const DEFAULT_INSTALL_DIR = "/opt/truckwash-edge-agent"; export const DEFAULT_INSTALL_DIR = "/opt/truckwash-edge-agent";
export const DEFAULT_RUNTIME_DIR = `${DEFAULT_INSTALL_DIR}/runtime`; export const DEFAULT_RUNTIME_DIR = `${DEFAULT_INSTALL_DIR}/runtime`;
export const DEFAULT_STATE_DATABASE_PATH = `${DEFAULT_RUNTIME_DIR}/gateway-state.sqlite`; export const DEFAULT_STATE_DATABASE_PATH = `${DEFAULT_RUNTIME_DIR}/gateway-state.sqlite`;
@@ -25,6 +25,16 @@ function composeArgs(projectName, args) {
return ["compose", "-p", projectName, ...args]; return ["compose", "-p", projectName, ...args];
} }
function usesWindowsPathSyntax(filePath) {
return /^[A-Za-z]:($|[\\/])/.test(filePath) || filePath.startsWith("\\\\") || filePath.includes("\\");
}
function pathForInputs(...filePaths) {
const hasWindowsPath = filePaths.some((filePath) => usesWindowsPathSyntax(String(filePath || "")));
return hasWindowsPath ? path.win32 : path;
}
async function resolveRootDir(scriptPath) { async function resolveRootDir(scriptPath) {
const cwd = process.cwd(); const cwd = process.cwd();
@@ -66,7 +76,7 @@ export function resolveComposeProjectName(rootDir, env = process.env) {
return explicit; return explicit;
} }
return path.basename(rootDir); return pathForInputs(rootDir).basename(rootDir);
} }
export function resolveComposeNetworkName(rootDir, env = process.env) { export function resolveComposeNetworkName(rootDir, env = process.env) {
@@ -74,11 +84,13 @@ export function resolveComposeNetworkName(rootDir, env = process.env) {
} }
export function resolveConfigDirectory(rootDir, explicitDir = null) { export function resolveConfigDirectory(rootDir, explicitDir = null) {
const pathModule = pathForInputs(rootDir, explicitDir);
if (explicitDir) { if (explicitDir) {
return path.resolve(rootDir, explicitDir); return pathModule.resolve(rootDir, explicitDir);
} }
return path.join(rootDir, ".tmp", "test-gateway"); return pathModule.join(rootDir, ".tmp", "test-gateway");
} }
export function shouldClaimGateway(existingConfig = {}, installToken = "") { export function shouldClaimGateway(existingConfig = {}, installToken = "") {
@@ -397,14 +409,20 @@ async function startContainer({
}); });
if (copyConfig) { if (copyConfig) {
await runCommand("docker", [ const configFilePath = path.join(configDir, DEFAULT_CONFIG_FILE_NAME);
"cp", await fs.chmod(configFilePath, 0o666).catch(() => {});
path.join(configDir, DEFAULT_CONFIG_FILE_NAME), try {
`${containerName}:${containerConfigPath}`, await runCommand("docker", [
], { "cp",
cwd: rootDir, configFilePath,
stdio: "inherit", `${containerName}:${containerConfigPath}`,
}); ], {
cwd: rootDir,
stdio: "inherit",
});
} finally {
await fs.chmod(configFilePath, 0o600).catch(() => {});
}
await runCommand("docker", ["start", containerName], { await runCommand("docker", ["start", containerName], {
cwd: rootDir, cwd: rootDir,
+5
View File
@@ -10,6 +10,11 @@
# CORS is handled at the edge by Traefik's headers middleware. # CORS is handled at the edge by Traefik's headers middleware.
# Do not set or strip Access-Control-* headers here to avoid conflicts. # Do not set or strip Access-Control-* headers here to avoid conflicts.
# Do not expose local replication bootstrap material from the public web root.
# Bootstrap snapshots contain sensitive failover credentials.
@replicationBootstrap path /storage/replication-bootstrap.json /storage/replication-bootstrap-*
respond @replicationBootstrap 404
# PHP handling via FastCGI to php-fpm pool # PHP handling via FastCGI to php-fpm pool
php_fastcgi php1:9000 php2:9000 php3:9000 php4:9000 php5:9000 php_fastcgi php1:9000 php2:9000 php3:9000 php4:9000 php5:9000
+5
View File
@@ -10,6 +10,11 @@
# CORS is handled at the edge by Traefik's headers middleware. # CORS is handled at the edge by Traefik's headers middleware.
# Do not set or strip Access-Control-* headers here to avoid conflicts. # Do not set or strip Access-Control-* headers here to avoid conflicts.
# Do not expose local replication bootstrap material from the public web root.
# Bootstrap snapshots contain sensitive failover credentials.
@replicationBootstrap path /storage/replication-bootstrap.json /storage/replication-bootstrap-*
respond @replicationBootstrap 404
# PHP handling via FastCGI to php-fpm pool # PHP handling via FastCGI to php-fpm pool
php_fastcgi php-staging:9000 php_fastcgi php-staging:9000
File diff suppressed because it is too large Load Diff
+90 -2
View File
@@ -18,6 +18,7 @@ const DEFAULT_UPDATE_VERIFY_INTERVAL_MS = 500;
const DEFAULT_UPDATE_RESTART_GRACE_MS = 150; const DEFAULT_UPDATE_RESTART_GRACE_MS = 150;
const DEFAULT_BROKER_RECONNECT_DELAY_MS = 1500; const DEFAULT_BROKER_RECONNECT_DELAY_MS = 1500;
const DEFAULT_SHELLY_LOCAL_HTTP_TIMEOUT_MS = 1200; const DEFAULT_SHELLY_LOCAL_HTTP_TIMEOUT_MS = 1200;
const MAX_RELAY_TOGGLE_AFTER_SECONDS = 5;
const UPDATE_VERIFY_COMMAND = "post-update-verify"; const UPDATE_VERIFY_COMMAND = "post-update-verify";
const execFile = promisify(execFileCallback); const execFile = promisify(execFileCallback);
@@ -151,6 +152,10 @@ function buildTransportHeartbeatState(brokerState = {}) {
}; };
} }
function isShellAccessEnabled(config = {}) {
return config.enableShellAccess === true;
}
function normalizeBrokerBaseUrl(value) { function normalizeBrokerBaseUrl(value) {
const trimmed = String(value || "").trim().replace(/\/+$/, ""); const trimmed = String(value || "").trim().replace(/\/+$/, "");
if (trimmed === "") { if (trimmed === "") {
@@ -478,7 +483,7 @@ function resolveRelayToggleAfterSeconds(payload = {}) {
return null; return null;
} }
return Math.floor(configured); return Math.min(Math.floor(configured), MAX_RELAY_TOGGLE_AFTER_SECONDS);
} }
async function fetchJson(url, fetchImpl = fetch, options = {}) { async function fetchJson(url, fetchImpl = fetch, options = {}) {
@@ -749,11 +754,74 @@ export async function setRelayState(payload, fetchImpl = fetch) {
} }
} }
async function mapWithConcurrency(items, limit, mapper) {
const results = new Array(items.length);
let nextIndex = 0;
const workerCount = Math.max(1, Math.min(Number(limit) || 1, items.length || 1));
await Promise.all(Array.from({ length: workerCount }, async () => {
while (nextIndex < items.length) {
const index = nextIndex;
nextIndex += 1;
results[index] = await mapper(items[index], index);
}
}));
return results;
}
async function executeRelayBatch(command, handler, fetchImpl = fetch) {
const commands = Array.isArray(command?.payload?.commands)
? command.payload.commands
: Array.isArray(command?.commands)
? command.commands
: [];
const concurrency = Math.max(1, Math.min(Number(command?.payload?.concurrency || command?.concurrency || 5), 5));
const results = await mapWithConcurrency(commands, concurrency, async (entry = {}) => {
const target = String(entry.target || entry.relay || "");
const relayId = String(entry.relayId || entry.relay_id || "");
try {
const payload = await handler(entry, fetchImpl);
return {
target,
relayId,
relay_id: relayId,
ok: true,
payload,
};
} catch (error) {
return {
target,
relayId,
relay_id: relayId,
ok: false,
error: error instanceof Error ? error.message : String(error),
};
}
});
return {
batchId: command?.payload?.batchId || command?.payload?.batch_id || command?.batchId || command?.batch_id || null,
batch_id: command?.payload?.batch_id || command?.payload?.batchId || command?.batch_id || command?.batchId || null,
results,
};
}
async function fetchArtifactBuffer(url, expectedSha256, label, fetchImpl = fetch) { async function fetchArtifactBuffer(url, expectedSha256, label, fetchImpl = fetch) {
if (!url) { if (!url) {
return null; return null;
} }
if (!expectedSha256) {
throw new Error(`${label} checksum is required`);
}
const normalizedExpectedSha256 = String(expectedSha256).toLowerCase();
if (!/^[a-f0-9]{64}$/.test(normalizedExpectedSha256)) {
throw new Error(`${label} checksum must be a valid sha256 hex digest`);
}
const response = await fetchImpl(url); const response = await fetchImpl(url);
if (!response.ok) { if (!response.ok) {
throw new Error(`${label} download failed: HTTP ${response.status}`); throw new Error(`${label} download failed: HTTP ${response.status}`);
@@ -761,7 +829,7 @@ async function fetchArtifactBuffer(url, expectedSha256, label, fetchImpl = fetch
const buffer = Buffer.from(await response.arrayBuffer()); const buffer = Buffer.from(await response.arrayBuffer());
const sha256 = createHash("sha256").update(buffer).digest("hex"); const sha256 = createHash("sha256").update(buffer).digest("hex");
if (expectedSha256 && String(expectedSha256).toLowerCase() !== sha256.toLowerCase()) { if (normalizedExpectedSha256 !== sha256.toLowerCase()) {
throw new Error(`${label} checksum mismatch`); throw new Error(`${label} checksum mismatch`);
} }
@@ -1402,6 +1470,10 @@ export async function handleAgentCommand(command, deps = {}) {
return await getRelayStatus(command.payload || {}, fetchImpl); return await getRelayStatus(command.payload || {}, fetchImpl);
case "SET_RELAY_STATE": case "SET_RELAY_STATE":
return await setRelayState(command.payload || {}, fetchImpl); return await setRelayState(command.payload || {}, fetchImpl);
case "BATCH_RELAY_STATUS":
return await executeRelayBatch(command, getRelayStatus, fetchImpl);
case "BATCH_SET_RELAY_STATE":
return await executeRelayBatch(command, setRelayState, fetchImpl);
case "RUN_UPDATE": case "RUN_UPDATE":
return await runUpdate(command.payload || {}, fetchImpl, deps); return await runUpdate(command.payload || {}, fetchImpl, deps);
case "UNINSTALL_AGENT": case "UNINSTALL_AGENT":
@@ -1785,6 +1857,10 @@ export async function processPolledShellAction(config, action, shell, fetchImpl
} }
try { try {
if (!isShellAccessEnabled(config)) {
throw new Error("Shell access is disabled by local configuration");
}
if (actionType === "OPEN") { if (actionType === "OPEN") {
await shell.open(payload); await shell.open(payload);
} else if (actionType === "INPUT") { } else if (actionType === "INPUT") {
@@ -1919,18 +1995,30 @@ function createBrokerBridge({
} }
if (message.type === "OPEN_ROOT_SHELL") { if (message.type === "OPEN_ROOT_SHELL") {
if (!isShellAccessEnabled(config)) {
throw new Error("Shell access is disabled by local configuration");
}
await shell.open(message.payload || {}); await shell.open(message.payload || {});
return; return;
} }
if (message.type === "SHELL_INPUT") { if (message.type === "SHELL_INPUT") {
if (!isShellAccessEnabled(config)) {
throw new Error("Shell access is disabled by local configuration");
}
shell.input(message.payload || {}); shell.input(message.payload || {});
return; return;
} }
if (message.type === "RESIZE_ROOT_SHELL") { if (message.type === "RESIZE_ROOT_SHELL") {
if (!isShellAccessEnabled(config)) {
throw new Error("Shell access is disabled by local configuration");
}
shell.resize(message.payload || {}); shell.resize(message.payload || {});
return; return;
} }
if (message.type === "CLOSE_ROOT_SHELL") { if (message.type === "CLOSE_ROOT_SHELL") {
if (!isShellAccessEnabled(config)) {
throw new Error("Shell access is disabled by local configuration");
}
shell.close(message.payload || {}); shell.close(message.payload || {});
} }
} catch { } catch {
+165 -2
View File
@@ -1,6 +1,7 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { execFile as execFileCallback } from "node:child_process"; import { execFile as execFileCallback } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
@@ -17,6 +18,7 @@ import {
getRelayStatus, getRelayStatus,
loadConfig, loadConfig,
parseCliArgs, parseCliArgs,
processPolledShellAction,
processPolledCommand, processPolledCommand,
runCli, runCli,
runUpdate, runUpdate,
@@ -39,6 +41,10 @@ function makeFetchResponse(body) {
}; };
} }
function sha256Hex(body) {
return createHash("sha256").update(body).digest("hex");
}
async function waitFor(predicate, { timeoutMs = 1000, intervalMs = 10, description = "condition" } = {}) { async function waitFor(predicate, { timeoutMs = 1000, intervalMs = 10, description = "condition" } = {}) {
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
@@ -121,6 +127,39 @@ test("relay status and switch commands support both Shelly RPC and legacy endpoi
assert.equal(switched.on, false); assert.equal(switched.on, false);
}); });
test("batch relay commands return per-relay results without failing the whole batch", async () => {
const fakeFetch = async (url) => {
const value = String(url);
if (value.includes("10.1.0.31")) {
return {
ok: true,
async json() {
return { output: true };
},
};
}
throw new Error("relay offline");
};
const result = await handleAgentCommand({
commandType: "BATCH_SET_RELAY_STATE",
payload: {
batch_id: "batch-1",
commands: [
{ target: "MACHINE", relayId: "relay-machine", localIp: "10.1.0.31", channel: 0, on: true },
{ target: "EXIT", relayId: "relay-out", localIp: "10.1.0.32", channel: 0, on: true },
],
},
}, { fetchImpl: fakeFetch });
assert.equal(result.batch_id, "batch-1");
assert.equal(result.results.length, 2);
assert.equal(result.results[0].ok, true);
assert.equal(result.results[0].target, "MACHINE");
assert.equal(result.results[1].ok, false);
assert.match(result.results[1].error, /relay offline/);
});
test("Shelly discovery infers Gen3 from S3 relay model codes when generation is omitted", async () => { test("Shelly discovery infers Gen3 from S3 relay model codes when generation is omitted", async () => {
const inventory = await discoverShellyDevices({ candidateIps: ["192.168.1.2"] }, async (url) => { const inventory = await discoverShellyDevices({ candidateIps: ["192.168.1.2"] }, async (url) => {
assert.equal(String(url), "http://192.168.1.2/shelly"); assert.equal(String(url), "http://192.168.1.2/shelly");
@@ -270,6 +309,31 @@ test("relay switch commands pass timer values to local Shelly APIs", async () =>
"http://10.1.0.31/rpc/Switch.Set?id=0&on=true&toggle_after=3", "http://10.1.0.31/rpc/Switch.Set?id=0&on=true&toggle_after=3",
"http://10.1.0.31/relay/0?turn=on&timer=3", "http://10.1.0.31/relay/0?turn=on&timer=3",
]); ]);
const cappedUrls = [];
const cappedFetch = async (url) => {
cappedUrls.push(String(url));
return {
ok: true,
async json() {
return { output: true };
},
};
};
await setRelayState({
localIp: "10.1.0.31",
channel: 0,
on: true,
toggle_after: 999999999,
device_generation: 3,
}, cappedFetch);
assert.equal(
cappedUrls[0],
"http://10.1.0.31/rpc/Switch.Set?id=0&on=true&toggle_after=5"
);
}); });
test("runUpdate stages a pending verification restart after installing new artifacts", async () => { test("runUpdate stages a pending verification restart after installing new artifacts", async () => {
@@ -294,19 +358,23 @@ test("runUpdate stages a pending verification restart after installing new artif
execCalls.push({ command, args, options }); execCalls.push({ command, args, options });
return { stdout: "{}" }; return { stdout: "{}" };
}; };
const agentBody = "// new agent\n";
const packageBody = JSON.stringify({ name: "new-edge-agent" }, null, 2);
const fakeFetch = async (url) => { const fakeFetch = async (url) => {
if (String(url).endsWith("/agent.mjs")) { if (String(url).endsWith("/agent.mjs")) {
return makeFetchResponse("// new agent\n"); return makeFetchResponse(agentBody);
} }
if (String(url).endsWith("/package.json")) { if (String(url).endsWith("/package.json")) {
return makeFetchResponse(JSON.stringify({ name: "new-edge-agent" }, null, 2)); return makeFetchResponse(packageBody);
} }
throw new Error(`Unexpected URL: ${url}`); throw new Error(`Unexpected URL: ${url}`);
}; };
const result = await runUpdate({ const result = await runUpdate({
artifactUrl: "https://api.example.test/edge-agent/artifacts/agent.mjs", artifactUrl: "https://api.example.test/edge-agent/artifacts/agent.mjs",
sha256: sha256Hex(agentBody),
packageUrl: "https://api.example.test/edge-agent/artifacts/package.json", packageUrl: "https://api.example.test/edge-agent/artifacts/package.json",
packageSha256: sha256Hex(packageBody),
targetVersion: "1.1.0", targetVersion: "1.1.0",
releaseChannel: "stable", releaseChannel: "stable",
restartMode: "spawn", restartMode: "spawn",
@@ -334,6 +402,45 @@ test("runUpdate stages a pending verification restart after installing new artif
await rm(tempDir, { recursive: true, force: true }); await rm(tempDir, { recursive: true, force: true });
}); });
test("runUpdate rejects artifacts without required checksums", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-update-checksum-"));
const configPath = path.join(tempDir, "config.json");
const liveConfig = {
apiUrl: "https://api.example.test",
gatewayId: 42,
agentToken: "agent-token",
installDir: tempDir,
restartMode: "spawn",
installedVersion: "1.0.0",
targetVersion: "1.0.0",
};
await writeFile(configPath, JSON.stringify(liveConfig, null, 2));
await writeFile(path.join(tempDir, "agent.mjs"), "// old agent\n");
let fetchCalled = false;
await assert.rejects(
runUpdate({
artifactUrl: "https://api.example.test/edge-agent/artifacts/agent.mjs",
targetVersion: "1.1.0",
}, async () => {
fetchCalled = true;
return makeFetchResponse("// new agent\n");
}, {
configPath,
config: liveConfig,
liveConfig,
execFileImpl: async () => ({ stdout: "{}" }),
}),
/Agent artifact checksum is required/
);
assert.equal(fetchCalled, false);
assert.equal(await readFile(path.join(tempDir, "agent.mjs"), "utf8"), "// old agent\n");
await rm(tempDir, { recursive: true, force: true });
});
test("handleAgentCommand returns an uninstall follow-up envelope for gateway removal", async () => { test("handleAgentCommand returns an uninstall follow-up envelope for gateway removal", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-uninstall-envelope-")); const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-uninstall-envelope-"));
@@ -670,6 +777,7 @@ test("startAgent reports API polling metadata, executes polled commands, and upl
commandPollRetryDelayMs: 5, commandPollRetryDelayMs: 5,
shellActionPollTimeoutSeconds: 0, shellActionPollTimeoutSeconds: 0,
shellActionPollRetryDelayMs: 5, shellActionPollRetryDelayMs: 5,
enableShellAccess: true,
})); }));
const heartbeats = []; const heartbeats = [];
@@ -928,6 +1036,61 @@ test("startAgent reports API polling metadata, executes polled commands, and upl
} }
}); });
test("processPolledShellAction denies shell access when locally disabled", async () => {
const submissions = [];
const fakeFetch = async (url, options = {}) => {
if (/\/shell-actions\/\d+\/result$/.test(String(url))) {
submissions.push({ url, body: JSON.parse(options.body) });
return {
ok: true,
async json() {
return { data: { acknowledged: true } };
},
};
}
throw new Error(`Unexpected URL: ${url}`);
};
const shell = {
async open() {
throw new Error("should not run");
},
input() {
throw new Error("should not run");
},
resize() {
throw new Error("should not run");
},
close() {
throw new Error("should not run");
},
};
const result = await processPolledShellAction(
{
apiUrl: "https://api.example.test",
gatewayId: 42,
agentToken: "agent-token",
enableShellAccess: false,
},
{
id: 501,
actionType: "OPEN",
payload: {
sessionId: 44,
},
},
shell,
fakeFetch
);
assert.equal(result.ok, false);
assert.match(result.error, /Shell access is disabled/);
assert.equal(submissions.length, 1);
assert.equal(submissions[0].body.ok, false);
});
test("status helpers report config without exposing the agent token", async () => { test("status helpers report config without exposing the agent token", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-status-")); const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-status-"));
const configPath = path.join(tempDir, "config.json"); const configPath = path.join(tempDir, "config.json");
+51 -18
View File
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
import { WebSocketServer } from "ws"; import { WebSocketServer } from "ws";
const DEFAULT_SHELL_OPEN_TIMEOUT_MS = 15000; const DEFAULT_SHELL_OPEN_TIMEOUT_MS = 15000;
const TELEMETRY_INGEST_ERROR_MESSAGE = "Telemetry ingestion failed";
function parseJsonBody(req) { function parseJsonBody(req) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -55,7 +56,33 @@ function resolveAuthMode(options = {}, managerUrl = "") {
if (process.env.EDGE_AUTH_MODE) { if (process.env.EDGE_AUTH_MODE) {
return process.env.EDGE_AUTH_MODE; return process.env.EDGE_AUTH_MODE;
} }
return "manager"; return "strict";
}
function resolveSharedSecret(options = {}) {
return String(options.sharedSecret ?? process.env.EDGE_BROKER_SHARED_SECRET ?? "").trim();
}
function requireSharedSecret(req, res, sharedSecret) {
if (sharedSecret === "") {
jsonResponse(res, 503, {
ok: false,
error: "Edge broker shared secret is not configured",
shared_secret_required: true,
});
return false;
}
if (req.headers["x-edge-broker-secret"] !== sharedSecret) {
jsonResponse(res, 403, {
ok: false,
error: "Forbidden",
shared_secret_required: true,
});
return false;
}
return true;
} }
function parseScopes(value) { function parseScopes(value) {
@@ -150,7 +177,7 @@ function rejectUpgrade(socket, statusCode, errorCode, message, details = {}) {
} }
export function createBrokerServer(options = {}) { export function createBrokerServer(options = {}) {
const sharedSecret = options.sharedSecret ?? process.env.EDGE_BROKER_SHARED_SECRET ?? ""; const sharedSecret = resolveSharedSecret(options);
const managerUrl = resolveManagerUrl(options); const managerUrl = resolveManagerUrl(options);
const authMode = resolveAuthMode(options, managerUrl); const authMode = resolveAuthMode(options, managerUrl);
const commandTimeoutMs = options.commandTimeoutMs ?? 10000; const commandTimeoutMs = options.commandTimeoutMs ?? 10000;
@@ -269,6 +296,12 @@ export function createBrokerServer(options = {}) {
? async (_gatewayId, payload = {}) => payload ? async (_gatewayId, payload = {}) => payload
: async (gatewayId, payload = {}) => : async (gatewayId, payload = {}) =>
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/logs`, payload)); managerRequest(`/edge-agent/internal/gateways/${gatewayId}/logs`, payload));
const ingestMachineSignal =
options.ingestMachineSignal ||
(authMode === "stub"
? async (_gatewayId, payload = {}) => payload
: async (gatewayId, payload = {}) =>
managerRequest(`/edge-agent/internal/gateways/${gatewayId}/selfserve/machine-signal`, payload));
const broadcastGatewayEvent = (gatewayId, message) => { const broadcastGatewayEvent = (gatewayId, message) => {
const sessionIds = gatewayStreamSessions.get(String(gatewayId)); const sessionIds = gatewayStreamSessions.get(String(gatewayId));
@@ -460,25 +493,19 @@ export function createBrokerServer(options = {}) {
} }
if (req.method === "POST" && url.pathname === "/api/diagnostics/shared-secret") { if (req.method === "POST" && url.pathname === "/api/diagnostics/shared-secret") {
if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) { if (!requireSharedSecret(req, res, sharedSecret)) {
jsonResponse(res, 403, {
ok: false,
error: "Forbidden",
shared_secret_required: true,
});
return; return;
} }
jsonResponse(res, 200, { jsonResponse(res, 200, {
ok: true, ok: true,
shared_secret_required: Boolean(sharedSecret), shared_secret_required: true,
}); });
return; return;
} }
if (req.method === "POST" && /^\/api\/gateways\/\d+\/commands$/.test(url.pathname)) { if (req.method === "POST" && /^\/api\/gateways\/\d+\/commands$/.test(url.pathname)) {
if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) { if (!requireSharedSecret(req, res, sharedSecret)) {
jsonResponse(res, 403, { error: "Forbidden" });
return; return;
} }
@@ -521,8 +548,7 @@ export function createBrokerServer(options = {}) {
} }
if (req.method === "POST" && /^\/api\/gateways\/\d+\/sync$/.test(url.pathname)) { if (req.method === "POST" && /^\/api\/gateways\/\d+\/sync$/.test(url.pathname)) {
if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) { if (!requireSharedSecret(req, res, sharedSecret)) {
jsonResponse(res, 403, { error: "Forbidden" });
return; return;
} }
@@ -556,12 +582,13 @@ export function createBrokerServer(options = {}) {
gatewayInfo = await validateAgent({ gatewayId, token, headers: req.headers }); gatewayInfo = await validateAgent({ gatewayId, token, headers: req.headers });
} catch (error) { } catch (error) {
const status = Number(error?.status) === 403 ? 403 : Number(error?.status) === 401 ? 401 : 503; const status = Number(error?.status) === 403 ? 403 : Number(error?.status) === 401 ? 401 : 503;
rejectUpgrade(socket, status, error?.code || "agent_validation_failed", normalizeErrorMessage(error, "Gateway agent could not be validated."), { rejectUpgrade(socket, status, error?.code || "agent_validation_failed", "Gateway agent could not be validated.", {
stage: "agent_validate", stage: "agent_validate",
}); });
return; return;
} }
wss.handleUpgrade(req, socket, head, (ws) => { wss.handleUpgrade(req, socket, head, (ws) => {
const existing = agents.get(gatewayId); const existing = agents.get(gatewayId);
if (existing && existing.readyState < 2) { if (existing && existing.readyState < 2) {
@@ -622,12 +649,13 @@ export function createBrokerServer(options = {}) {
try { try {
session = await validateShellSession({ token, headers: req.headers }); session = await validateShellSession({ token, headers: req.headers });
} catch (error) { } catch (error) {
rejectUpgrade(socket, Number(error?.status) === 403 ? 403 : 401, error?.code || "shell_session_invalid", normalizeErrorMessage(error, "Shell session could not be validated."), { rejectUpgrade(socket, Number(error?.status) === 403 ? 403 : 401, error?.code || "shell_session_invalid", "Shell session could not be validated.", {
stage: "shell_session_validate", stage: "shell_session_validate",
}); });
return; return;
} }
wss.handleUpgrade(req, socket, head, (ws) => { wss.handleUpgrade(req, socket, head, (ws) => {
ws.sessionToken = token; ws.sessionToken = token;
ws.sessionInfo = session; ws.sessionInfo = session;
@@ -722,7 +750,7 @@ export function createBrokerServer(options = {}) {
return; return;
} }
} catch (error) { } catch (error) {
rejectUpgrade(socket, 500, "websocket_upgrade_failed", normalizeErrorMessage(error, "WebSocket upgrade failed.")); rejectUpgrade(socket, 500, "websocket_upgrade_failed", "WebSocket upgrade failed.");
return; return;
} }
@@ -765,8 +793,8 @@ export function createBrokerServer(options = {}) {
let ingestError = null; let ingestError = null;
try { try {
ingested = await ingestTelemetry(String(ws.gatewayId), payload); ingested = await ingestTelemetry(String(ws.gatewayId), payload);
} catch (error) { } catch {
ingestError = error instanceof Error ? error.message : String(error); ingestError = TELEMETRY_INGEST_ERROR_MESSAGE;
} }
const fallbackStatistics = { const fallbackStatistics = {
system_metrics: payload?.metadata?.system_metrics || {}, system_metrics: payload?.metadata?.system_metrics || {},
@@ -831,6 +859,11 @@ export function createBrokerServer(options = {}) {
return; return;
} }
if (message.type === "MACHINE_SIGNAL") {
await ingestMachineSignal(String(ws.gatewayId), message.payload || {});
return;
}
if (["SHELL_OUTPUT", "SHELL_OPENED", "SHELL_EXIT"].includes(message.type)) { if (["SHELL_OUTPUT", "SHELL_OPENED", "SHELL_EXIT"].includes(message.type)) {
const sessionRecord = browserShellSessions.get(String(message.sessionId)); const sessionRecord = browserShellSessions.get(String(message.sessionId));
if (!sessionRecord) { if (!sessionRecord) {
+152 -5
View File
@@ -19,6 +19,18 @@ function waitForClose(socket) {
}); });
} }
function waitForCloseOrError(socket) {
return new Promise((resolve) => {
const onDone = () => {
socket.off("error", onDone);
socket.off("close", onDone);
resolve();
};
socket.once("error", onDone);
socket.once("close", onDone);
});
}
function rawUpgradeRequest(port, path) { function rawUpgradeRequest(port, path) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const socket = net.createConnection({ host: "127.0.0.1", port }, () => { const socket = net.createConnection({ host: "127.0.0.1", port }, () => {
@@ -59,7 +71,7 @@ async function waitFor(predicate, { timeoutMs = 1000, intervalMs = 10, descripti
throw new Error(`Timed out waiting for ${description}`); throw new Error(`Timed out waiting for ${description}`);
} }
test("broker defaults to manager auth and fails closed when manager URL is missing", async () => { test("broker defaults to strict auth and fails closed when manager URL is missing", async () => {
const previousEnv = { const previousEnv = {
EDGE_AUTH_MODE: process.env.EDGE_AUTH_MODE, EDGE_AUTH_MODE: process.env.EDGE_AUTH_MODE,
EDGE_MANAGER_URL: process.env.EDGE_MANAGER_URL, EDGE_MANAGER_URL: process.env.EDGE_MANAGER_URL,
@@ -72,7 +84,7 @@ test("broker defaults to manager auth and fails closed when manager URL is missi
let broker; let broker;
try { try {
broker = createBrokerServer({ sharedSecret: "secret" }); broker = createBrokerServer({ sharedSecret: "secret" });
assert.equal(broker.state.authMode, "manager"); assert.equal(broker.state.authMode, "strict");
assert.equal(broker.state.managerUrl, ""); assert.equal(broker.state.managerUrl, "");
const address = await broker.listen(0); const address = await broker.listen(0);
@@ -83,13 +95,15 @@ test("broker defaults to manager auth and fails closed when manager URL is missi
assert.doesNotMatch(shellResponse, /101 Switching Protocols/); assert.doesNotMatch(shellResponse, /101 Switching Protocols/);
assert.match(shellResponse, /^HTTP\/1\.1 401 Unauthorized/m); assert.match(shellResponse, /^HTTP\/1\.1 401 Unauthorized/m);
assert.match(shellResponse, /"error_code":"shell_session_invalid"/); assert.match(shellResponse, /"error_code":"shell_session_invalid"/);
assert.match(shellResponse, /Edge manager URL is not configured/); assert.match(shellResponse, /"message":"Shell session could not be validated\."/);
assert.doesNotMatch(shellResponse, /Edge manager URL is not configured/);
assert.doesNotMatch(agentResponse, /101 Switching Protocols/); assert.doesNotMatch(agentResponse, /101 Switching Protocols/);
assert.match(agentResponse, /^HTTP\/1\.1 503 Service Unavailable/m); assert.match(agentResponse, /^HTTP\/1\.1 503 Service Unavailable/m);
assert.match(agentResponse, /"error_code":"agent_validation_failed"/); assert.match(agentResponse, /"error_code":"agent_validation_failed"/);
assert.match(agentResponse, /"stage":"agent_validate"/); assert.match(agentResponse, /"stage":"agent_validate"/);
assert.match(agentResponse, /Edge manager URL is not configured/); assert.match(agentResponse, /"message":"Gateway agent could not be validated\."/);
assert.doesNotMatch(agentResponse, /Edge manager URL is not configured/);
} finally { } finally {
if (broker) { if (broker) {
await broker.close(); await broker.close();
@@ -104,6 +118,53 @@ test("broker defaults to manager auth and fails closed when manager URL is missi
} }
}); });
test("broker rejects protected HTTP endpoints when shared secret is missing", async () => {
const broker = createBrokerServer({ authMode: "stub", sharedSecret: "", commandTimeoutMs: 2000 });
const address = await broker.listen(0);
const port = address.port;
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
await new Promise((resolve) => agent.once("open", resolve));
const agentMessages = collectMessages(agent);
const commandResponse = await fetch(`http://127.0.0.1:${port}/api/gateways/701/commands`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
commandType: "SET_RELAY_STATE",
payload: { relayId: "M-7", on: true },
}),
});
const commandJson = await commandResponse.json();
assert.equal(commandResponse.status, 503);
assert.equal(commandJson.ok, false);
assert.equal(commandJson.shared_secret_required, true);
assert.match(commandJson.error, /shared secret is not configured/);
assert.equal(agentMessages.some((message) => message.type === "COMMAND"), false);
const diagnosticsResponse = await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, {
method: "POST",
});
const diagnosticsJson = await diagnosticsResponse.json();
assert.equal(diagnosticsResponse.status, 503);
assert.equal(diagnosticsJson.shared_secret_required, true);
const syncResponse = await fetch(`http://127.0.0.1:${port}/api/gateways/701/sync`, {
method: "POST",
});
const syncJson = await syncResponse.json();
assert.equal(syncResponse.status, 503);
assert.equal(syncJson.shared_secret_required, true);
agent.terminate();
await broker.close();
});
test("broker dispatches commands to connected agents", async () => { test("broker dispatches commands to connected agents", async () => {
const broker = createBrokerServer({ authMode: "stub", sharedSecret: "secret", commandTimeoutMs: 2000 }); const broker = createBrokerServer({ authMode: "stub", sharedSecret: "secret", commandTimeoutMs: 2000 });
const address = await broker.listen(0); const address = await broker.listen(0);
@@ -338,11 +399,34 @@ test("broker rejects invalid browser shell upgrades without leaking the token",
assert.match(response, /^HTTP\/1\.1 401 Unauthorized/m); assert.match(response, /^HTTP\/1\.1 401 Unauthorized/m);
assert.match(response, /"error_code":"shell_session_expired"/); assert.match(response, /"error_code":"shell_session_expired"/);
assert.match(response, /"message":"Shell session could not be validated\."/);
assert.doesNotMatch(response, /Shell session expired/);
assert.doesNotMatch(response, new RegExp(rawToken)); assert.doesNotMatch(response, new RegExp(rawToken));
await broker.close(); await broker.close();
}); });
test("broker rejects websocket upgrade errors without exposing exception text", async () => {
const broker = createBrokerServer({
authMode: "stub",
validateBrowserStream: async () => {
throw new Error("UPSTREAM-SENSITIVE: redis://cache.internal:6379 timeout");
},
});
const address = await broker.listen(0);
const port = address.port;
const response = await rawUpgradeRequest(port, "/ws/browser-gateway-stream?token=session-token");
assert.match(response, /^HTTP\/1\.1 500 Internal Server Error/m);
assert.match(response, /"error_code":"websocket_upgrade_failed"/);
assert.match(response, /"message":"WebSocket upgrade failed\."/);
assert.doesNotMatch(response, /UPSTREAM-SENSITIVE/);
assert.doesNotMatch(response, /redis:\/\/cache\.internal/);
await broker.close();
});
test("broker closes browser shell sessions when the agent never reports shell opened", async () => { test("broker closes browser shell sessions when the agent never reports shell opened", async () => {
const closedSessions = []; const closedSessions = [];
const broker = createBrokerServer({ const broker = createBrokerServer({
@@ -418,6 +502,17 @@ test("broker closes browser shell sessions when the agent disconnects before she
await broker.close(); await broker.close();
}); });
test("broker defaults to strict auth when no validators are configured", async () => {
const broker = createBrokerServer();
const address = await broker.listen(0);
const port = address.port;
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
await waitForCloseOrError(agent);
await broker.close();
});
test("broker sends an agent welcome before connection progress and backlog dispatch", async () => { test("broker sends an agent welcome before connection progress and backlog dispatch", async () => {
const broker = createBrokerServer({ const broker = createBrokerServer({
authMode: "stub", authMode: "stub",
@@ -671,6 +766,51 @@ test("broker fans out telemetry, task, log, and presence updates to browser gate
await broker.close(); await broker.close();
}); });
test("broker ingests self-serve machine signals from connected agents", async () => {
const machineSignals = [];
const broker = createBrokerServer({
authMode: "stub",
validateAgent: async () => ({ id: "701", gateway_id: "701", label: "CPH Edge 01" }),
ingestMachineSignal: async (gatewayId, payload) => {
machineSignals.push({ gatewayId, payload });
return { recorded: true, lane_id: payload.lane_id };
},
});
const address = await broker.listen(0);
const port = address.port;
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
await new Promise((resolve) => agent.once("open", resolve));
agent.send(
JSON.stringify({
type: "MACHINE_SIGNAL",
payload: {
lane_id: 3,
relay_id: "machine-relay",
component: "input",
channel: 0,
event: "input.toggle_on",
state: true,
},
})
);
await waitFor(() => machineSignals.length === 1, { description: "machine signal ingestion" });
assert.equal(machineSignals[0].gatewayId, "701");
assert.deepEqual(machineSignals[0].payload, {
lane_id: 3,
relay_id: "machine-relay",
component: "input",
channel: 0,
event: "input.toggle_on",
state: true,
});
agent.terminate();
await broker.close();
});
test("broker survives telemetry ingestion failures for stale gateways", async () => { test("broker survives telemetry ingestion failures for stale gateways", async () => {
const broker = createBrokerServer({ const broker = createBrokerServer({
authMode: "stub", authMode: "stub",
@@ -743,9 +883,16 @@ test("broker still fans out telemetry when manager ingestion fails", async () =>
); );
await waitFor( await waitFor(
() => browserMessages.some((message) => message.type === "gateway.telemetry" && message.error === "manager unavailable"), () =>
browserMessages.some(
(message) => message.type === "gateway.telemetry" && message.error === "Telemetry ingestion failed"
),
{ description: "telemetry fanout after ingest failure" } { description: "telemetry fanout after ingest failure" }
); );
assert.ok(
browserMessages.every((message) => message.error !== "manager unavailable"),
"raw manager errors must not be sent to browser streams"
);
assert.ok( assert.ok(
browserMessages.some( browserMessages.some(
(message) => message.type === "stats.updated" && message.statistics?.system_metrics?.cpu_usage_pct === 31 (message) => message.type === "stats.updated" && message.statistics?.system_metrics?.cpu_usage_pct === 31
+5 -5
View File
@@ -39,7 +39,7 @@ test("traefik does not expose a dedicated public edge broker port", () => {
test("base docker compose routes edge broker traffic through traefik", () => { test("base docker compose routes edge broker traffic through traefik", () => {
const serviceBlock = readComposeServiceBlock(baseComposeSource, "edge-broker"); const serviceBlock = readComposeServiceBlock(baseComposeSource, "edge-broker");
assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/); assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/);
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-manager\}/); assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-strict\}/);
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/); assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.priority=200/); assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.priority=200/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.priority=200/); assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.priority=200/);
@@ -55,7 +55,7 @@ test("base docker compose routes edge broker traffic through traefik", () => {
test("example docker compose routes edge broker traffic through traefik", () => { test("example docker compose routes edge broker traffic through traefik", () => {
const serviceBlock = readComposeServiceBlock(exampleComposeSource, "edge-broker"); const serviceBlock = readComposeServiceBlock(exampleComposeSource, "edge-broker");
assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/); assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/);
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-manager\}/); assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-strict\}/);
assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/); assert.match(serviceBlock, /EDGE_MANAGER_URL:\s*\$\{EDGE_MANAGER_URL:-http:\/\/caddy\}/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.example\.com`\) && PathPrefix\(`\/edge-broker`\)/); assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.example\.com`\) && PathPrefix\(`\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.rule=Host\(`localhost`\) && PathPrefix\(`\/api\/edge-broker`\)/); assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.rule=Host\(`localhost`\) && PathPrefix\(`\/api\/edge-broker`\)/);
@@ -76,10 +76,10 @@ test("standalone production compose routes edge broker traffic through traefik",
assert.match(serviceBlock, /traefik\.http\.services\.edge-broker\.loadbalancer\.server\.port=4300/); assert.match(serviceBlock, /traefik\.http\.services\.edge-broker\.loadbalancer\.server\.port=4300/);
}); });
test("php services receive broker websocket environment defaults", () => { test("compose config does not provide insecure broker secret defaults", () => {
for (const composeSource of [baseComposeSource, exampleComposeSource]) { for (const composeSource of [baseComposeSource, exampleComposeSource]) {
assert.match(composeSource, /EDGE_BROKER_URL:\s*\$\{EDGE_BROKER_URL:-http:\/\/edge-broker:4300\}/); assert.match(composeSource, /EDGE_BROKER_URL:\s*\$\{EDGE_BROKER_URL:-http:\/\/edge-broker:4300\}/);
assert.match(composeSource, /EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev\}/); assert.match(composeSource, /EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:\?set EDGE_BROKER_SHARED_SECRET in \.env\}/);
} }
}); });
@@ -88,6 +88,6 @@ test("base docker compose wires the broker into each php worker", () => {
const serviceBlock = readComposeServiceBlock(baseComposeSource, serviceName); const serviceBlock = readComposeServiceBlock(baseComposeSource, serviceName);
assert.match(serviceBlock, /\n\s+depends_on:\s*\n[\s\S]*?\n\s+- edge-broker/); assert.match(serviceBlock, /\n\s+depends_on:\s*\n[\s\S]*?\n\s+- edge-broker/);
assert.match(serviceBlock, /EDGE_BROKER_URL:\s*\$\{EDGE_BROKER_URL:-http:\/\/edge-broker:4300\}/); assert.match(serviceBlock, /EDGE_BROKER_URL:\s*\$\{EDGE_BROKER_URL:-http:\/\/edge-broker:4300\}/);
assert.match(serviceBlock, /EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev\}/); assert.match(serviceBlock, /EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:\?set EDGE_BROKER_SHARED_SECRET in \.env\}/);
} }
}); });
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -133,8 +133,14 @@ class attachments implements attachments_i
protected function fetchAttachmentRows(string $type, array $object_ids, array $options = []): array protected function fetchAttachmentRows(string $type, array $object_ids, array $options = []): array
{ {
$options = $this->normalizeAttachmentOptions($options); $options = $this->normalizeAttachmentOptions($options);
$rawType = trim($type, '`');
$objectTypes = array_values(array_unique([
$rawType,
'`' . $rawType . '`',
]));
return (new object_attachments_o())->getFieldsWhereIn([ return (new object_attachments_o())->getFieldsWhereIn([
'object_type' => $type, 'object_type' => $objectTypes,
'object_id' => $object_ids, 'object_id' => $object_ids,
'deleted_at' => null 'deleted_at' => null
], $options); ], $options);
+15 -12
View File
@@ -6,6 +6,7 @@ use classes\totp;
use Exception; use Exception;
use interfaces\authentication_i; use interfaces\authentication_i;
use objects\plate_scanners_o; use objects\plate_scanners_o;
use objects\subuser_grants_o;
use objects\tokens_o; use objects\tokens_o;
use objects\users_o; use objects\users_o;
use objects\subusers_o; use objects\subusers_o;
@@ -125,9 +126,13 @@ class authentication implements authentication_i
public function validate_token(string $token): bool public function validate_token(string $token): bool
{ {
// First: try validating as a classic user auth token // First: try validating as a classic user auth token
$dbToken = (new tokens_o())->getToken($token); try {
if ($dbToken && $dbToken->id) { $dbToken = (new tokens_o())->getToken($token);
return true; if ($dbToken && $dbToken->id && $dbToken->type->value() === 'AUTH_TOKEN') {
return true;
}
} catch (Exception) {
// Ignore and continue to subuser session validation
} }
// Fallback: try validating as a subuser session token // Fallback: try validating as a subuser session token
$subuser = (new subusers_o())->getSubuserBySessionToken($token); $subuser = (new subusers_o())->getSubuserBySessionToken($token);
@@ -154,19 +159,17 @@ class authentication implements authentication_i
// Strip the Bearer prefix // Strip the Bearer prefix
$rawToken = str_replace('Bearer ', '', $rawToken); $rawToken = str_replace('Bearer ', '', $rawToken);
// Get the token from the database // Get the token from the database
$token = (new tokens_o())->getToken($rawToken); try {
$token = (new tokens_o())->getToken($rawToken);
} catch (Exception) {
return false;
}
// Check if the token exists // Check if the token exists
if (!$token->id) { if (!$token->id) {
return false; return false;
} }
if ($token->type->value() === "AUTH_TOKEN_SUBUSER") { if ($token->type->value() !== 'AUTH_TOKEN') {
// Get the customer number from the headers return false;
if (!isset($headers['X-Customer-Number'])) {
return false;
}
$customer_number = (int)$headers['X-Customer-Number'];
// Get the user by the customer number
return (new users_o())->getUserByCustomerNumber($customer_number);
} }
// Get the user from the database // Get the user from the database
$user = (new users_o())->getUserById($token->user_id->value()); $user = (new users_o())->getUserById($token->user_id->value());
@@ -121,6 +121,16 @@ class coolify_api_client
]); ]);
} }
public function listApplicationEnvs(string $uuid): array
{
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/envs');
}
public function deleteApplicationEnv(string $uuid, string $envUuid): array
{
return $this->request('DELETE', '/applications/' . rawurlencode($uuid) . '/envs/' . rawurlencode($envUuid));
}
private static function bulkEnvData(array $env): array private static function bulkEnvData(array $env): array
{ {
$data = []; $data = [];
@@ -159,6 +169,11 @@ class coolify_api_client
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/restart'); return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/restart');
} }
public function stopApplication(string $uuid): array
{
return $this->request('GET', '/applications/' . rawurlencode($uuid) . '/stop');
}
public function deleteService(string $uuid): array public function deleteService(string $uuid): array
{ {
return $this->request('DELETE', '/services/' . rawurlencode($uuid)); return $this->request('DELETE', '/services/' . rawurlencode($uuid));
+266 -3
View File
@@ -1371,6 +1371,129 @@ class coolify_manager
]; ];
} }
public function deployGithubRunners(array $input, ?int $actorUserId = null): array
{
$this->ensureSchema();
$dryRun = $this->toBool($input['dry_run'] ?? null, false);
$instanceId = (int)($input['instance_id'] ?? 0);
if ($instanceId <= 0) {
$instanceId = $this->defaultInstanceId();
}
$instance = $this->getInstance($instanceId);
$repositories = $this->githubRunnerRepositories($input);
$labels = $this->githubRunnerLabels($input['labels'] ?? null);
$countPerRepo = $this->githubRunnerCount($input['count_per_repo'] ?? $input['runner_count_per_repo'] ?? null);
$serviceName = $this->githubRunnerServiceName($input['service_name'] ?? null);
$resourceUuid = $this->nullableString($input['service_uuid'] ?? null)
?? $this->nullableString($this->coolifyConfigValue('github_runner_service_uuid', ''));
$token = $this->githubRunnerToken($input);
$template = $this->githubRunnerComposeTemplate($repositories, $labels, $countPerRepo);
$hash = $this->composeHash($template);
$plan = [
'type' => 'deploy_github_runners',
'instance_id' => $instanceId,
'service_uuid' => $resourceUuid,
'service_name' => $serviceName,
'repositories' => $repositories,
'labels' => $labels,
'count_per_repo' => $countPerRepo,
'compose_hash' => $hash,
'action' => $resourceUuid === null ? 'create' : 'update',
'token_set' => $token !== '',
'token_source' => trim((string)($input['github_token'] ?? $input['token'] ?? '')) !== '' ? 'request' : 'config',
];
if ($dryRun) {
$this->audit(null, $instanceId, null, 'github_runners_planned', $actorUserId, 'info', $plan);
return [
'ok' => true,
'dry_run' => true,
'mutated' => false,
'planned' => [$plan],
'applied' => [],
'errors' => [],
'service_uuid' => $resourceUuid,
'service_name' => $serviceName,
'compose_hash' => $hash,
'repositories' => $repositories,
'labels' => $labels,
'count_per_repo' => $countPerRepo,
];
}
if ($token === '') {
throw new RuntimeException('GitHub runner token is required to deploy self-hosted runners.');
}
$client = $this->clientForInstance($instance);
$apiResult = [];
$action = $resourceUuid === null ? 'created' : 'updated';
if ($resourceUuid === null) {
$apiResult = $client->createService($this->githubRunnerServicePayload($instance, $input, $serviceName, $template, false));
$resourceUuid = trim((string)($apiResult['uuid'] ?? ''));
if ($resourceUuid === '') {
throw new RuntimeException('Coolify did not return a GitHub runner service UUID.');
}
} else {
try {
$apiResult = $client->updateService($resourceUuid, $this->githubRunnerServicePayload($instance, $input, $serviceName, $template, true));
} catch (Throwable $throwable) {
if (!str_contains(strtolower($throwable->getMessage()), '404')
&& !str_contains(strtolower($throwable->getMessage()), 'not found')) {
throw $throwable;
}
$apiResult = $client->createService($this->githubRunnerServicePayload($instance, $input, $serviceName, $template, false));
$resourceUuid = trim((string)($apiResult['uuid'] ?? ''));
if ($resourceUuid === '') {
throw new RuntimeException('Coolify did not return a GitHub runner service UUID.');
}
$action = 'created';
}
}
$client->updateServiceEnvsBulk($resourceUuid, ['GITHUB_RUNNER_TOKEN' => $token]);
$start = $this->startOrRestartService($client, $resourceUuid, $action === 'updated');
$deployment = $client->deployResource($resourceUuid, false);
$this->setModuleConfigValue('Coolify', 'github_runner_service_uuid', $resourceUuid, 'string');
$this->setModuleConfigValue('Coolify', 'github_runner_frontend_repository', $repositories['frontend'], 'string');
$this->setModuleConfigValue('Coolify', 'github_runner_backend_repository', $repositories['backend'], 'string');
$this->setModuleConfigValue('Coolify', 'github_runner_labels', implode(',', $labels), 'string');
$this->setModuleConfigValue('Coolify', 'github_runner_count_per_repo', (string)$countPerRepo, 'int');
if ($this->toBool($input['persist_token'] ?? null, false)) {
$this->setModuleConfigValue('Coolify', 'github_runner_token', replication_secret_box::encrypt($token), 'string');
}
$applied = array_replace($plan, [
'action' => $action,
'service_uuid' => $resourceUuid,
'coolify' => self::redactCoolifyResponse($apiResult),
'start' => self::redactCoolifyResponse(is_array($start) ? $start : []),
'deployment' => self::redactCoolifyResponse($deployment),
]);
$this->audit(null, $instanceId, null, 'github_runners_deployed', $actorUserId, 'info', $applied);
return [
'ok' => true,
'dry_run' => false,
'mutated' => true,
'planned' => [$plan],
'applied' => [$applied],
'errors' => [],
'action' => $action,
'service_uuid' => $resourceUuid,
'service_name' => $serviceName,
'compose_hash' => $hash,
'repositories' => $repositories,
'labels' => $labels,
'count_per_repo' => $countPerRepo,
'deployment' => self::redactCoolifyResponse($deployment),
];
}
private function gatewayApiCodeVersionLabel(array $target): string private function gatewayApiCodeVersionLabel(array $target): string
{ {
$channelSlug = trim((string)($target['channel_slug'] ?? 'gateway')); $channelSlug = trim((string)($target['channel_slug'] ?? 'gateway'));
@@ -1926,13 +2049,14 @@ class coolify_manager
], $actorUserId); ], $actorUserId);
} }
$deployment = $releaseManager->startDeployment([ $sourceCommitSha = trim((string)($sourceTarget['latest_deployment_commit_sha'] ?? ''));
$deploymentInput = [
'target_id' => (int)($deploymentTarget['id'] ?? 0), 'target_id' => (int)($deploymentTarget['id'] ?? 0),
'channel_id' => (int)$sourceTarget['channel_id'], 'channel_id' => (int)$sourceTarget['channel_id'],
'app' => $app, 'app' => $app,
'repository' => (string)($sourceTarget['repository'] ?? ''), 'repository' => (string)($sourceTarget['repository'] ?? ''),
'branch' => (string)($sourceTarget['branch'] ?? 'master'), 'branch' => (string)($sourceTarget['branch'] ?? 'master'),
'commit_mode' => 'latest', 'commit_mode' => $sourceCommitSha === '' ? 'latest' : 'specific',
'version_label' => $this->gatewayRouteProvisionVersionLabel($sourceTarget), 'version_label' => $this->gatewayRouteProvisionVersionLabel($sourceTarget),
'deployed_url' => $sourcePublicUrl, 'deployed_url' => $sourcePublicUrl,
'metadata' => [ 'metadata' => [
@@ -1942,7 +2066,11 @@ class coolify_manager
'server_uuid' => $serverUuid, 'server_uuid' => $serverUuid,
'app' => $app, 'app' => $app,
], ],
], $actorUserId); ];
if ($sourceCommitSha !== '') {
$deploymentInput['commit_sha'] = $sourceCommitSha;
}
$deployment = $releaseManager->startDeployment($deploymentInput, $actorUserId);
if ((string)($deployment['status'] ?? '') !== 'deployed') { if ((string)($deployment['status'] ?? '') !== 'deployed') {
$errors[] = array_replace($action, [ $errors[] = array_replace($action, [
@@ -3261,6 +3389,141 @@ class coolify_manager
]; ];
} }
private function githubRunnerRepositories(array $input): array
{
return [
'frontend' => $this->normalizeGithubRepository(
$input['frontend_repository'] ?? $input['frontend_repo'] ?? $this->coolifyConfigValue('github_runner_frontend_repository', 'copenhagentruckwash/pleno-vue'),
'frontend'
),
'backend' => $this->normalizeGithubRepository(
$input['backend_repository'] ?? $input['backend_repo'] ?? $this->coolifyConfigValue('github_runner_backend_repository', 'copenhagentruckwash/api'),
'backend'
),
];
}
private function normalizeGithubRepository(mixed $value, string $label): string
{
$repository = trim((string)$value);
$repository = preg_replace('#^https://github\.com/#i', '', $repository) ?? $repository;
$repository = preg_replace('#^git@github\.com:#i', '', $repository) ?? $repository;
$repository = preg_replace('#\.git$#i', '', $repository) ?? $repository;
$repository = trim($repository, " \t\n\r\0\x0B/");
if (!preg_match('#^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$#', $repository)) {
throw new RuntimeException('GitHub ' . $label . ' repository must be in owner/repo format.');
}
return $repository;
}
private function githubRunnerLabels(mixed $value): array
{
$raw = trim((string)($value ?? ''));
if ($raw === '') {
$raw = $this->coolifyConfigValue('github_runner_labels', 'self-hosted,Linux,X64,default');
}
$labels = array_values(array_unique(array_filter(array_map(
static fn(string $label): string => trim($label),
preg_split('/[,\s]+/', $raw) ?: []
))));
return $labels !== [] ? $labels : ['self-hosted', 'Linux', 'X64', 'default'];
}
private function githubRunnerCount(mixed $value): int
{
$count = (int)($value ?? 0);
if ($count <= 0) {
$count = (int)$this->coolifyConfigValue('github_runner_count_per_repo', '1');
}
return max(1, min(10, $count));
}
private function githubRunnerServiceName(mixed $value): string
{
$name = strtolower(trim((string)($value ?? 'truckwash-github-runners')));
$name = preg_replace('/[^a-z0-9-]+/', '-', $name) ?: '';
$name = trim($name, '-') ?: 'truckwash-github-runners';
return substr($name, 0, 120);
}
private function githubRunnerToken(array $input): string
{
$token = trim((string)($input['github_token'] ?? $input['token'] ?? ''));
if ($token !== '') {
return $token;
}
$envToken = trim((string)(getenv('GITHUB_RUNNER_TOKEN') ?: getenv('GITHUB_TOKEN') ?: ''));
if ($envToken !== '') {
return $envToken;
}
return replication_secret_box::decrypt($this->coolifyConfigValue('github_runner_token', ''));
}
private function githubRunnerComposeTemplate(array $repositories, array $labels, int $countPerRepo): array
{
$lines = ['services:'];
foreach ($repositories as $key => $repository) {
for ($index = 1; $index <= $countPerRepo; $index++) {
$service = 'github-runner-' . $key . '-' . $index;
$runnerName = 'truckwash-' . $key . '-' . $index;
$runnerLabels = array_values(array_unique(array_merge($labels, [$key])));
$lines = array_merge($lines, [
' ' . $service . ':',
' image: myoung34/github-runner:latest',
' restart: unless-stopped',
' environment:',
' REPO_URL: ' . self::yamlScalar('https://github.com/' . $repository),
' RUNNER_NAME: ' . self::yamlScalar($runnerName),
' RUNNER_SCOPE: repo',
' RUNNER_WORKDIR: /tmp/runner/work',
' LABELS: ' . self::yamlScalar(implode(',', $runnerLabels)),
' EPHEMERAL: "false"',
' RUN_AS_ROOT: "true"',
' ACCESS_TOKEN: ${GITHUB_RUNNER_TOKEN}',
' volumes:',
' - /var/run/docker.sock:/var/run/docker.sock',
]);
}
}
return [
'compose' => implode("\n", $lines) . "\n",
'env' => 'GITHUB_RUNNER_TOKEN=${GITHUB_RUNNER_TOKEN}',
];
}
private function githubRunnerServicePayload(array $instance, array $input, string $serviceName, array $template, bool $update): array
{
$payload = [
'name' => $serviceName,
'description' => 'Truckwash GitHub self-hosted runners for frontend and backend workflows.',
'instant_deploy' => false,
'docker_compose_raw' => $this->encodedDockerCompose($template),
'force_domain_override' => false,
];
if (!$update) {
$payload = array_replace($payload, [
'project_uuid' => $this->targetMapping($input, $instance, 'project_uuid'),
'environment_name' => $this->targetMapping($input, $instance, 'environment_name') ?: 'production',
'environment_uuid' => $this->targetMapping($input, $instance, 'environment_uuid'),
'server_uuid' => $this->targetMapping($input, $instance, 'server_uuid'),
'destination_uuid' => $this->targetMapping($input, $instance, 'destination_uuid'),
]);
}
return array_filter($payload, static fn($value): bool => $value !== null && $value !== '');
}
private static function yamlScalar(string $value): string
{
return '"' . str_replace(['\\', '"'], ['\\\\', '\\"'], $value) . '"';
}
private function publicLoadBalancerConfig(array $config): array private function publicLoadBalancerConfig(array $config): array
{ {
unset($config['token']); unset($config['token']);
@@ -154,6 +154,12 @@ class coolify_schema_bootstrap
self::ensureModuleConfigDefault('Coolify', 'hetzner_cloud_api_token', '', 'string'); self::ensureModuleConfigDefault('Coolify', 'hetzner_cloud_api_token', '', 'string');
self::ensureModuleConfigDefault('Coolify', 'public_gateway_host', 'api-v2.truckwash.io', 'string'); self::ensureModuleConfigDefault('Coolify', 'public_gateway_host', 'api-v2.truckwash.io', 'string');
self::ensureModuleConfigDefault('Coolify', 'public_gateway_probe_path', '', 'string'); self::ensureModuleConfigDefault('Coolify', 'public_gateway_probe_path', '', 'string');
self::ensureModuleConfigDefault('Coolify', 'github_runner_token', '', 'string');
self::ensureModuleConfigDefault('Coolify', 'github_runner_service_uuid', '', 'string');
self::ensureModuleConfigDefault('Coolify', 'github_runner_frontend_repository', 'copenhagentruckwash/pleno-vue', 'string');
self::ensureModuleConfigDefault('Coolify', 'github_runner_backend_repository', 'copenhagentruckwash/api', 'string');
self::ensureModuleConfigDefault('Coolify', 'github_runner_labels', 'self-hosted,Linux,X64,default', 'string');
self::ensureModuleConfigDefault('Coolify', 'github_runner_count_per_repo', '1', 'int');
self::ensureDefaultGateway('node1.truckwash.io', '94.130.142.41', 10); self::ensureDefaultGateway('node1.truckwash.io', '94.130.142.41', 10);
self::ensureDefaultGateway('node2.truckwash.io', '65.21.214.30', 20); self::ensureDefaultGateway('node2.truckwash.io', '65.21.214.30', 20);
@@ -6,6 +6,7 @@ class cors_policy
{ {
public const ALLOWED_HEADERS = 'Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma, *'; public const ALLOWED_HEADERS = 'Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma, *';
public const ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS'; public const ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
public const EXPOSED_HEADERS = 'Server-Timing';
public const MAX_AGE_SECONDS = '86400'; public const MAX_AGE_SECONDS = '86400';
private const REQUIRED_ALLOWED_ORIGINS = [ private const REQUIRED_ALLOWED_ORIGINS = [
@@ -25,6 +26,9 @@ class cors_policy
'https://localhost:4433', 'https://localhost:4433',
'https://twdev.jeppeb.dk', 'https://twdev.jeppeb.dk',
'http://localhost:5173', 'http://localhost:5173',
'http://localhost:5174',
'http://127.0.0.1:5173',
'http://127.0.0.1:5174',
]; ];
public static function normalizeOrigin(?string $value): string public static function normalizeOrigin(?string $value): string
@@ -125,7 +129,9 @@ class cors_policy
'Access-Control-Allow-Credentials' => 'true', 'Access-Control-Allow-Credentials' => 'true',
'Access-Control-Allow-Headers' => self::ALLOWED_HEADERS, 'Access-Control-Allow-Headers' => self::ALLOWED_HEADERS,
'Access-Control-Allow-Methods' => self::ALLOWED_METHODS, 'Access-Control-Allow-Methods' => self::ALLOWED_METHODS,
'Access-Control-Expose-Headers' => self::EXPOSED_HEADERS,
'Access-Control-Max-Age' => self::MAX_AGE_SECONDS, 'Access-Control-Max-Age' => self::MAX_AGE_SECONDS,
'Timing-Allow-Origin' => $origin,
'Vary' => 'Origin', 'Vary' => 'Origin',
]; ];
} }
@@ -240,7 +240,13 @@ class economic_transfer_executor
'Queued transfer processed successfully for collected invoice #' . $collected_invoice_id 'Queued transfer processed successfully for collected invoice #' . $collected_invoice_id
); );
return $collected_order_invoices->asArray(); $result = $collected_order_invoices->asArray();
$transfer_metrics = $collected_order_invoices->getLastEconomicTransferMetrics();
if ($transfer_metrics !== null) {
$result['economic_transfer_metrics'] = $transfer_metrics;
}
return $result;
} }
/** /**
@@ -41,7 +41,7 @@ class economic_transfer_queue
$transfer_type = $this->validateTransferType($transfer_type); $transfer_type = $this->validateTransferType($transfer_type);
$payload = $this->normalizePayloadForTransferType($transfer_type, $payload, $created_by); $payload = $this->normalizePayloadForTransferType($transfer_type, $payload, $created_by);
$active_job = $this->findActiveJobByTarget($transfer_type, $payload); $active_job = $this->findActiveJobByTarget($transfer_type, $payload, $created_by);
if ($active_job !== null) { if ($active_job !== null) {
$target_label = $this->buildTargetLabel($transfer_type, $payload); $target_label = $this->buildTargetLabel($transfer_type, $payload);
$this->logQueueEvent( $this->logQueueEvent(
@@ -135,6 +135,89 @@ class economic_transfer_queue
return $jobs; return $jobs;
} }
public function getJobByIdForUser(int $job_id, int $created_by): ?array
{
global $db;
$job_id = max(0, $job_id);
$created_by = max(0, $created_by);
if ($job_id < 1 || $created_by < 1) {
return null;
}
$stmt = $db->prepare("SELECT * FROM economic_transfer_queue_jobs WHERE id = ? AND created_by = ? LIMIT 1");
if (!$stmt) {
return null;
}
$stmt->bind_param('ii', $job_id, $created_by);
if (!$stmt->execute()) {
$stmt->close();
return null;
}
$result = $stmt->get_result();
$row = $result instanceof mysqli_result ? $result->fetch_assoc() : null;
$stmt->close();
if (!$row) {
return null;
}
return $this->normalizeJobRow($row);
}
public function listJobsForCreatedBy(array $statuses = [], int $limit = 50, int $offset = 0, ?string $transfer_type = null, int $created_by = 0): array
{
global $db;
$created_by = max(0, $created_by);
if ($created_by < 1) {
return [];
}
$limit = max(1, min(500, $limit));
$offset = max(0, $offset);
$where = $this->buildListJobsWhereClause($statuses, $transfer_type);
$where .= $where === '' ? 'WHERE created_by = ' . $created_by : ' AND created_by = ' . $created_by;
$sql = "SELECT * FROM economic_transfer_queue_jobs $where ORDER BY id DESC LIMIT $limit OFFSET $offset";
$result = $db->query($sql);
if (!$result instanceof mysqli_result) {
return [];
}
$jobs = [];
while ($row = $result->fetch_assoc()) {
$jobs[] = $this->normalizeJobRow($row);
}
return $jobs;
}
public function countJobsForCreatedBy(array $statuses = [], ?string $transfer_type = null, int $created_by = 0): int
{
global $db;
$created_by = max(0, $created_by);
if ($created_by < 1) {
return 0;
}
$where = $this->buildListJobsWhereClause($statuses, $transfer_type);
$where .= $where === '' ? 'WHERE created_by = ' . $created_by : ' AND created_by = ' . $created_by;
$sql = "SELECT COUNT(*) AS total FROM economic_transfer_queue_jobs $where";
$result = $db->query($sql);
if (!$result instanceof mysqli_result) {
return 0;
}
$row = $result->fetch_assoc();
if (!is_array($row) || !isset($row['total'])) {
return 0;
}
return max(0, (int)$row['total']);
}
public function countJobs(array $statuses = [], ?string $transfer_type = null): int public function countJobs(array $statuses = [], ?string $transfer_type = null): int
{ {
global $db; global $db;
@@ -179,7 +262,7 @@ class economic_transfer_queue
ON d.queue_job_id = q.id ON d.queue_job_id = q.id
AND d.user_id = $user_id AND d.user_id = $user_id
AND d.dismissed_status = q.status AND d.dismissed_status = q.status
WHERE 1 = 1 WHERE q.created_by = $user_id
$transfer_condition $transfer_condition
AND ( AND (
q.status IN ('" . self::STATUS_QUEUED . "', '" . self::STATUS_PROCESSING . "') q.status IN ('" . self::STATUS_QUEUED . "', '" . self::STATUS_PROCESSING . "')
@@ -214,7 +297,7 @@ class economic_transfer_queue
throw new Exception('Queue job and user are required'); throw new Exception('Queue job and user are required');
} }
$job = $this->getJobById($job_id); $job = $this->getJobByIdForUser($job_id, $user_id);
if ($job === null) { if ($job === null) {
throw new Exception('Queue job not found'); throw new Exception('Queue job not found');
} }
@@ -272,7 +355,8 @@ class economic_transfer_queue
ON d.queue_job_id = q.id ON d.queue_job_id = q.id
AND d.user_id = $user_id AND d.user_id = $user_id
AND d.dismissed_status = q.status AND d.dismissed_status = q.status
WHERE q.status IN ('" . self::STATUS_COMPLETED . "', '" . self::STATUS_FAILED . "') WHERE q.created_by = $user_id
AND q.status IN ('" . self::STATUS_COMPLETED . "', '" . self::STATUS_FAILED . "')
$transfer_condition $transfer_condition
AND d.queue_job_id IS NULL AND d.queue_job_id IS NULL
ON DUPLICATE KEY UPDATE dismissed_status = VALUES(dismissed_status), dismissed_at = NOW()"; ON DUPLICATE KEY UPDATE dismissed_status = VALUES(dismissed_status), dismissed_at = NOW()";
@@ -284,10 +368,24 @@ class economic_transfer_queue
* @throws Exception * @throws Exception
*/ */
public function retryJob(int $job_id): array public function retryJob(int $job_id): array
{
return $this->retryJobInternal($job_id);
}
public function retryJobForUser(int $job_id, int $created_by): array
{
return $this->retryJobInternal($job_id, $created_by);
}
private function retryJobInternal(int $job_id, ?int $created_by = null): array
{ {
global $db; global $db;
$existing_job = $this->getJobById($job_id); $job_id = max(0, $job_id);
$created_by = $created_by === null ? null : max(0, $created_by);
$existing_job = $created_by === null
? $this->getJobById($job_id)
: $this->getJobByIdForUser($job_id, $created_by);
if ($existing_job === null) { if ($existing_job === null) {
throw new Exception('Queue job not found'); throw new Exception('Queue job not found');
} }
@@ -298,19 +396,26 @@ class economic_transfer_queue
throw new Exception('Queue job reached max retry attempts'); throw new Exception('Queue job reached max retry attempts');
} }
$stmt = $db->prepare( $sql = "UPDATE economic_transfer_queue_jobs
"UPDATE economic_transfer_queue_jobs
SET status = ?, progress_percent = 0, progress_message = 'Queued for retry', SET status = ?, progress_percent = 0, progress_message = 'Queued for retry',
error_message = NULL, result_json = NULL, started_at = NULL, completed_at = NULL, locked_at = NULL error_message = NULL, result_json = NULL, started_at = NULL, completed_at = NULL, locked_at = NULL
WHERE id = ? AND status = ?" WHERE id = ? AND status = ?";
); if ($created_by !== null) {
$sql .= " AND created_by = ?";
}
$stmt = $db->prepare($sql);
if (!$stmt) { if (!$stmt) {
throw new Exception('Failed to prepare retry statement'); throw new Exception('Failed to prepare retry statement');
} }
$queued = self::STATUS_QUEUED; $queued = self::STATUS_QUEUED;
$failed = self::STATUS_FAILED; $failed = self::STATUS_FAILED;
$stmt->bind_param('sis', $queued, $job_id, $failed); if ($created_by !== null) {
$stmt->bind_param('sisi', $queued, $job_id, $failed, $created_by);
} else {
$stmt->bind_param('sis', $queued, $job_id, $failed);
}
$stmt->execute(); $stmt->execute();
$affected = $stmt->affected_rows; $affected = $stmt->affected_rows;
$stmt->close(); $stmt->close();
@@ -321,7 +426,9 @@ class economic_transfer_queue
$this->clearDismissalsForJob($job_id); $this->clearDismissalsForJob($job_id);
$job = $this->getJobById($job_id); $job = $created_by === null
? $this->getJobById($job_id)
: $this->getJobByIdForUser($job_id, $created_by);
if ($job === null) { if ($job === null) {
throw new Exception('Retry updated job could not be loaded'); throw new Exception('Retry updated job could not be loaded');
} }
@@ -710,28 +817,31 @@ class economic_transfer_queue
return $this->rejectPayload($created_by, $field_name . ' must be a boolean'); return $this->rejectPayload($created_by, $field_name . ' must be a boolean');
} }
private function findActiveJobByTarget(string $transfer_type, array $payload): ?array private function findActiveJobByTarget(string $transfer_type, array $payload, int $created_by): ?array
{ {
return match ($transfer_type) { return match ($transfer_type) {
self::TYPE_ORDER_DRAFT_EXPORT, self::TYPE_ORDER_INVOICE_EXPORT => $this->findActiveJobByJsonNumericTarget( self::TYPE_ORDER_DRAFT_EXPORT, self::TYPE_ORDER_INVOICE_EXPORT => $this->findActiveJobByJsonNumericTarget(
$transfer_type, $transfer_type,
'$.order_id', '$.order_id',
(int)($payload['order_id'] ?? 0) (int)($payload['order_id'] ?? 0),
$created_by
), ),
self::TYPE_COLLECTED_INVOICE_EXPORT => $this->findActiveJobByJsonNumericTarget( self::TYPE_COLLECTED_INVOICE_EXPORT => $this->findActiveJobByJsonNumericTarget(
$transfer_type, $transfer_type,
'$.collected_invoice_id', '$.collected_invoice_id',
(int)($payload['collected_invoice_id'] ?? 0) (int)($payload['collected_invoice_id'] ?? 0),
$created_by
), ),
default => null, default => null,
}; };
} }
private function findActiveJobByJsonNumericTarget(string $transfer_type, string $json_path, int $target_value): ?array private function findActiveJobByJsonNumericTarget(string $transfer_type, string $json_path, int $target_value, int $created_by): ?array
{ {
global $db; global $db;
if ($target_value < 1) { $created_by = max(0, $created_by);
if ($target_value < 1 || $created_by < 1) {
return null; return null;
} }
@@ -741,6 +851,7 @@ class economic_transfer_queue
WHERE transfer_type = ? WHERE transfer_type = ?
AND status IN (?, ?) AND status IN (?, ?)
AND CAST(JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$json_path')) AS UNSIGNED) = ? AND CAST(JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$json_path')) AS UNSIGNED) = ?
AND created_by = ?
ORDER BY id DESC ORDER BY id DESC
LIMIT 1" LIMIT 1"
); );
@@ -750,7 +861,7 @@ class economic_transfer_queue
$queued = self::STATUS_QUEUED; $queued = self::STATUS_QUEUED;
$processing = self::STATUS_PROCESSING; $processing = self::STATUS_PROCESSING;
$stmt->bind_param('sssi', $transfer_type, $queued, $processing, $target_value); $stmt->bind_param('sssii', $transfer_type, $queued, $processing, $target_value, $created_by);
if (!$stmt->execute()) { if (!$stmt->execute()) {
$stmt->close(); $stmt->close();
return null; return null;
@@ -0,0 +1,150 @@
<?php
namespace classes;
use Exception;
class edge_broker_transport_exception extends Exception
{
public function __construct(string $message, private readonly int $curlErrno = 0, int $code = 0, ?Exception $previous = null)
{
parent::__construct($message, $code, $previous);
}
public function curlErrno(): int
{
return $this->curlErrno;
}
}
class edge_broker_http_exception extends Exception
{
public function __construct(string $message, private readonly int $statusCode, int $code = 0, ?Exception $previous = null)
{
parent::__construct($message, $code, $previous);
}
public function statusCode(): int
{
return $this->statusCode;
}
}
class edge_broker_client
{
private const DEFAULT_BROKER_URL = 'http://edge-broker:4300';
public function __construct(
private readonly ?string $baseUrl = null,
private readonly ?string $sharedSecret = null,
private readonly int $timeoutSeconds = 10
) {
}
public function isConfigured(): bool
{
return trim((string)$this->resolveBaseUrl()) !== '';
}
public function dispatchCommand(int $gatewayId, string $commandType, array $payload): array
{
$url = rtrim($this->resolveBaseUrl(), '/') . '/api/gateways/' . $gatewayId . '/commands';
$response = $this->request('POST', $url, [
'commandType' => $commandType,
'payload' => $payload,
]);
return is_array($response) ? $response : ['ok' => false, 'response' => $response];
}
public function validateAgent(int $gatewayId, string $agentToken): array
{
$url = rtrim($this->resolveBaseUrl(), '/') . '/api/internal/agent/auth';
$response = $this->request('POST', $url, [
'gatewayId' => $gatewayId,
'agentToken' => $agentToken,
]);
return is_array($response) ? $response : [];
}
public function validateShellSession(string $sessionToken): array
{
$url = rtrim($this->resolveBaseUrl(), '/') . '/api/internal/shell/auth';
$response = $this->request('POST', $url, [
'sessionToken' => $sessionToken,
]);
return is_array($response) ? $response : [];
}
public function closeShellSession(int $sessionId, string $sessionToken, string $transcript, string $closedReason): array
{
$url = rtrim($this->resolveBaseUrl(), '/') . '/api/internal/shell-sessions/' . $sessionId . '/close';
$response = $this->request('POST', $url, [
'sessionToken' => $sessionToken,
'transcript' => $transcript,
'closedReason' => $closedReason,
]);
return is_array($response) ? $response : [];
}
private function resolveBaseUrl(): string
{
return trim((string)($this->baseUrl ?? getenv('EDGE_BROKER_URL') ?: self::DEFAULT_BROKER_URL));
}
private function resolveSharedSecret(): string
{
return trim((string)($this->sharedSecret
?? getenv('EDGE_BROKER_SHARED_SECRET')
?: getenv('EDGE_INTERNAL_SECRET')
?: ''));
}
/**
* @throws Exception
*/
private function request(string $method, string $url, array $payload): array|object|null
{
if (trim($url) === '') {
throw new Exception('Edge broker URL is not configured');
}
$sharedSecret = $this->resolveSharedSecret();
if ($sharedSecret === '') {
throw new Exception('Edge broker shared secret is not configured');
}
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeoutSeconds);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'X-Edge-Broker-Secret: ' . $sharedSecret,
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
$rawResponse = curl_exec($ch);
$statusCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErrno = curl_errno($ch);
$curlError = curl_error($ch);
curl_close($ch);
if ($rawResponse === false) {
throw new edge_broker_transport_exception('Edge broker request failed: ' . $curlError, $curlErrno);
}
$decoded = json_decode((string)$rawResponse, true);
if ($statusCode >= 400) {
$message = is_array($decoded)
? (string)($decoded['error'] ?? $decoded['message'] ?? 'Edge broker request failed')
: 'Edge broker request failed';
throw new edge_broker_http_exception($message, $statusCode);
}
return $decoded;
}
}
+111 -2
View File
@@ -128,13 +128,13 @@ use Psr\Http\Client\ClientExceptionInterface;
private function sendEmailMailerSend(string $to, string $recipient_name, string $subject, string $message, string $html = null, string $references = null, array $attachments = []): void private function sendEmailMailerSend(string $to, string $recipient_name, string $subject, string $message, string $html = null, string $references = null, array $attachments = []): void
{ {
if (self::isFakeDeliveryEnabled()) { if (self::isFakeDeliveryEnabled()) {
self::$fake_deliveries[] = [ self::recordFakeDelivery([
'to' => $to, 'to' => $to,
'recipient_name' => $recipient_name, 'recipient_name' => $recipient_name,
'subject' => $subject, 'subject' => $subject,
'message' => $message, 'message' => $message,
'html' => $html, 'html' => $html,
]; ]);
return; return;
} }
@@ -225,6 +225,72 @@ use Psr\Http\Client\ClientExceptionInterface;
public static function resetFakeDeliveries(): void public static function resetFakeDeliveries(): void
{ {
self::$fake_deliveries = []; self::$fake_deliveries = [];
$path = self::getFakeDeliveriesPath();
if ($path !== null && is_file($path)) {
unlink($path);
}
}
public static function syncFakeDeliveries(): void
{
$path = self::getFakeDeliveriesPath();
if ($path === null || !is_file($path)) {
self::$fake_deliveries = [];
return;
}
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines === false) {
self::$fake_deliveries = [];
return;
}
$deliveries = [];
foreach ($lines as $line) {
$delivery = json_decode($line, true);
if (is_array($delivery)) {
$deliveries[] = $delivery;
}
}
self::$fake_deliveries = $deliveries;
}
private static function recordFakeDelivery(array $delivery): void
{
self::$fake_deliveries[] = $delivery;
$path = self::getFakeDeliveriesPath();
if ($path === null) {
return;
}
$directory = dirname($path);
if (!is_dir($directory)) {
mkdir($directory, 0777, true);
}
file_put_contents($path, json_encode($delivery, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL, FILE_APPEND | LOCK_EX);
}
private static function getFakeDeliveriesPath(): ?string
{
if (!self::isFakeDeliveryEnabled()) {
return null;
}
$configuredPath = trim((string)(getenv('EMAIL_FAKE_DELIVERIES_PATH') ?: ''));
if ($configuredPath !== '') {
return $configuredPath;
}
if (getenv('RUN_API_TESTS') !== '1') {
return null;
}
return rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR)
. DIRECTORY_SEPARATOR
. 'truckwash-email-fake-deliveries-' . md5((string)getcwd()) . '.jsonl';
} }
private static function isFakeDeliveryEnabled(): bool private static function isFakeDeliveryEnabled(): bool
@@ -511,4 +577,47 @@ use Psr\Http\Client\ClientExceptionInterface;
$this->attachments $this->attachments
); );
} }
/**
* @throws Exception
*/
public function sendNewCustomerRegistrationNotifications(int $customer_number): void
{
$customer = (new users_o())->getUserByCustomerNumber($customer_number);
if (!$customer->exists()) {
throw new Exception('Customer not found with customer number: ' . $customer_number);
}
$customerName = $customer->getCustomerName((int)$customer->customer_number->value()) ?: 'Unknown customer';
$safeCustomerName = htmlspecialchars($customerName, ENT_QUOTES, 'UTF-8');
$safeCustomerNumber = (int)$customer->customer_number->value();
$customerUrl = 'https://truckwash.io/superuser/users?search=' . $safeCustomerNumber;
$message = "
<p>A new customer has registered on truckwash.io.</p>
<p>
<strong>Customer number:</strong> $safeCustomerNumber<br>
<strong>Customer name:</strong> $safeCustomerName
</p>
<p><a href='$customerUrl'>Open customer in Superuser</a></p>
";
foreach ((new users_o())->getSuperuserNewCustomerEmailNotificationRecipients() as $recipient) {
$recipientEmail = trim((string)($recipient['email'] ?? ''));
if ($recipientEmail === '') {
continue;
}
$recipientName = trim((string)($recipient['display_name'] ?? ''));
if ($recipientName === '') {
$recipientName = $recipientEmail;
}
$this->sendEmail(
$recipientEmail,
$recipientName,
'New customer registered on Truck Wash',
$message,
);
}
}
} }
@@ -38,6 +38,8 @@ class gateway_shelly_transport implements shelly_transport_i
return match ($endpoint) { return match ($endpoint) {
'/v2/devices/api/get' => $this->handleGetStates($department_id, $data), '/v2/devices/api/get' => $this->handleGetStates($department_id, $data),
'/v2/devices/api/set/switch' => $this->handleSetSwitch($department_id, $data), '/v2/devices/api/set/switch' => $this->handleSetSwitch($department_id, $data),
'/v2/devices/api/batch/get' => $this->handleBatchGetStates($department_id, $data),
'/v2/devices/api/batch/set/switch' => $this->handleBatchSetSwitch($department_id, $data),
default => throw new Exception('Unsupported gateway Shelly transport endpoint: ' . $endpoint), default => throw new Exception('Unsupported gateway Shelly transport endpoint: ' . $endpoint),
}; };
} }
@@ -95,6 +97,52 @@ class gateway_shelly_transport implements shelly_transport_i
return [$this->normalizeRelayPayload($logicalRelayId, $status)]; return [$this->normalizeRelayPayload($logicalRelayId, $status)];
} }
/**
* @throws Exception
*/
private function handleBatchGetStates(int $departmentId, array $data): array
{
$requests = [];
foreach ((array)($data['commands'] ?? $data['targets'] ?? []) as $entry) {
$command = is_array($entry) ? $entry : ['relay_id' => $entry];
$relayId = trim((string)($command['relay_id'] ?? $command['relayId'] ?? $command['id'] ?? ''));
if ($relayId === '') {
continue;
}
$requests[] = [
'target' => strtoupper(trim((string)($command['target'] ?? $relayId))),
'relay_id' => $relayId,
];
}
return $this->manager()->queueRelayStatusBatch($departmentId, $requests, null, $this->localOnly);
}
/**
* @throws Exception
*/
private function handleBatchSetSwitch(int $departmentId, array $data): array
{
$requests = [];
foreach ((array)($data['commands'] ?? []) as $entry) {
if (!is_array($entry)) {
continue;
}
$relayId = trim((string)($entry['relay_id'] ?? $entry['relayId'] ?? $entry['id'] ?? ''));
if ($relayId === '') {
continue;
}
$requests[] = [
'target' => strtoupper(trim((string)($entry['target'] ?? $relayId))),
'relay_id' => $relayId,
'on' => (bool)($entry['on'] ?? false),
'toggle_after' => $entry['toggle_after'] ?? $entry['toggleAfter'] ?? $entry['timer'] ?? null,
];
}
return $this->manager()->queueRelaySwitchBatch($departmentId, $requests, null, $this->localOnly);
}
/** /**
* @param array<string,mixed> $status * @param array<string,mixed> $status
* @return array<string,mixed> * @return array<string,mixed>
@@ -320,6 +320,16 @@ class invoice_period_flag_service
} }
public function warmManualFlagsCache(): void public function warmManualFlagsCache(): void
{
$flags = $this->fetchActiveManualFlagsFromDb();
try {
(new redis())->cache_invoice_period_manual_flags($flags);
} catch (Throwable) {
}
}
private function fetchActiveManualFlagsFromDb(): array
{ {
global $db; global $db;
@@ -337,10 +347,7 @@ class invoice_period_flag_service
} }
} }
try { return $flags;
(new redis())->cache_invoice_period_manual_flags($flags);
} catch (Throwable) {
}
} }
private function formatStoredFlag(array $row): array private function formatStoredFlag(array $row): array
@@ -388,15 +395,11 @@ class invoice_period_flag_service
} }
if (!is_array($flags)) { if (!is_array($flags)) {
// Cache miss — warm on demand and re-fetch // Cache miss — read from the database and refresh Redis without hiding active flags.
$this->warmManualFlagsCache(); $flags = $this->fetchActiveManualFlagsFromDb();
try { try {
$flags = (new redis())->get_invoice_period_manual_flags(); (new redis())->cache_invoice_period_manual_flags($flags);
} catch (Throwable) { } catch (Throwable) {
return [];
}
if (!is_array($flags)) {
return [];
} }
} }
@@ -525,16 +528,12 @@ class invoice_period_flag_service
try { try {
$flags = (new redis())->get_invoice_period_automatic_flags($dateFrom, $dateTo); $flags = (new redis())->get_invoice_period_automatic_flags($dateFrom, $dateTo);
} catch (Throwable) { } catch (Throwable) {
return []; $flags = null;
} }
if (!is_array($flags)) { if (!is_array($flags)) {
// Cache miss — enqueue for warming on the next cron run $flags = $this->calculateAutomaticFlagsForPeriod($dateFrom, $dateTo);
try { $this->cacheAutomaticFlagsForPeriod($dateFrom, $dateTo, $flags);
(new redis())->enqueue_invoice_period_warming($dateFrom, $dateTo);
} catch (Throwable) {
}
return [];
} }
if ($onlyCustomerNumbers === null) { if ($onlyCustomerNumbers === null) {
@@ -549,17 +548,31 @@ class invoice_period_flag_service
public function warmAutomaticFlagsForPeriod(string $dateFrom, string $dateTo): void public function warmAutomaticFlagsForPeriod(string $dateFrom, string $dateTo): void
{ {
[$dateFrom, $dateTo] = $this->normalizePeriodDateRange($dateFrom, $dateTo);
$this->cacheAutomaticFlagsForPeriod(
$dateFrom,
$dateTo,
$this->calculateAutomaticFlagsForPeriod($dateFrom, $dateTo)
);
}
private function calculateAutomaticFlagsForPeriod(string $dateFrom, string $dateTo): array
{
[$dateFrom, $dateTo] = $this->normalizePeriodDateRange($dateFrom, $dateTo);
$rows = $this->getPeriodOrderItemRows($dateFrom, $dateTo, null); $rows = $this->getPeriodOrderItemRows($dateFrom, $dateTo, null);
$attributes = $this->getCustomerAttributes(null); $attributes = $this->getCustomerAttributes(null);
$flags = array_merge( return array_merge(
$this->detectCustomerRuleViolations($rows, $attributes), $this->detectCustomerRuleViolations($rows, $attributes),
$this->detectPriceMismatches($rows), $this->detectPriceMismatches($rows),
$this->detectAbnormalQuantities($rows, $dateFrom, $dateTo), $this->detectAbnormalQuantities($rows, $dateFrom, $dateTo),
$this->detectVehicleTypeMismatches($rows, $dateFrom), $this->detectVehicleTypeMismatches($rows, $dateFrom),
$this->detectMissingXlVaskLinks($dateFrom, $dateTo, null) $this->detectMissingXlVaskLinks($dateFrom, $dateTo, null)
); );
}
private function cacheAutomaticFlagsForPeriod(string $dateFrom, string $dateTo, array $flags): void
{
try { try {
(new redis())->cache_invoice_period_automatic_flags($dateFrom, $dateTo, $flags); (new redis())->cache_invoice_period_automatic_flags($dateFrom, $dateTo, $flags);
} catch (Throwable) { } catch (Throwable) {
@@ -603,14 +616,19 @@ class invoice_period_flag_service
private function getPeriodOrderItemRows(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers): array private function getPeriodOrderItemRows(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers): array
{ {
[$dateFrom, $dateTo] = $this->normalizePeriodDateRange($dateFrom, $dateTo);
try { try {
$rows = (new redis())->get_invoice_period_order_item_rows($dateFrom, $dateTo); $rows = (new redis())->get_invoice_period_order_item_rows($dateFrom, $dateTo);
} catch (Throwable) { } catch (Throwable) {
return []; $rows = null;
} }
if (!is_array($rows)) { if (!is_array($rows)) {
return []; $rows = $this->fetchOrderItemRowsFromDb($dateFrom, $dateTo);
try {
(new redis())->cache_invoice_period_order_item_rows($dateFrom, $dateTo, $rows);
} catch (Throwable) {
}
} }
$this->seedOrderItemsPreviewCacheFromRows($rows); $this->seedOrderItemsPreviewCacheFromRows($rows);
@@ -627,6 +645,7 @@ class invoice_period_flag_service
public function warmOrderItemRowsForPeriod(string $dateFrom, string $dateTo): void public function warmOrderItemRowsForPeriod(string $dateFrom, string $dateTo): void
{ {
[$dateFrom, $dateTo] = $this->normalizePeriodDateRange($dateFrom, $dateTo);
$rows = $this->fetchOrderItemRowsFromDb($dateFrom, $dateTo); $rows = $this->fetchOrderItemRowsFromDb($dateFrom, $dateTo);
try { try {
(new redis())->cache_invoice_period_order_item_rows($dateFrom, $dateTo, $rows); (new redis())->cache_invoice_period_order_item_rows($dateFrom, $dateTo, $rows);
@@ -634,6 +653,24 @@ class invoice_period_flag_service
} }
} }
private function normalizePeriodDateRange(string $dateFrom, string $dateTo): array
{
return [
$this->normalizePeriodDate($dateFrom, true),
$this->normalizePeriodDate($dateTo, false),
];
}
private function normalizePeriodDate(string $date, bool $startOfDay): string
{
$timestamp = strtotime($date);
if ($timestamp === false) {
return $date;
}
return date($startOfDay ? 'Y-m-d 00:00:00' : 'Y-m-d 23:59:59', $timestamp);
}
private function fetchOrderItemRowsFromDb(string $dateFrom, string $dateTo): array private function fetchOrderItemRowsFromDb(string $dateFrom, string $dateTo): array
{ {
global $db; global $db;
@@ -1727,18 +1764,7 @@ class invoice_period_flag_service
if ($currentVehicleType === '' || $expectedVehicleType === '') { if ($currentVehicleType === '' || $expectedVehicleType === '') {
return false; return false;
} }
if ($currentVehicleType === $expectedVehicleType) { return $currentVehicleType === $expectedVehicleType;
return true;
}
// Allow a match if one normalized name's tokens are a subset of the other.
// E.g. "Indvendig vask Kassevogn" → "kassevogn" is a subset of
// "Kassevogn/varevogn" → "kassevogn varevogn", meaning the same vehicle type.
$currentTokens = explode(' ', $currentVehicleType);
$expectedTokens = explode(' ', $expectedVehicleType);
if (count($currentTokens) <= count($expectedTokens)) {
return array_diff($currentTokens, $expectedTokens) === [];
}
return array_diff($expectedTokens, $currentTokens) === [];
} }
private function normalizePrimaryVehicleProductName(string $productName): string private function normalizePrimaryVehicleProductName(string $productName): string
@@ -10,6 +10,22 @@ use licenseplaterecognizer\licenseplaterecognizer_c;
class licenseplaterecognizer implements licenseplaterecognizer_i class licenseplaterecognizer implements licenseplaterecognizer_i
{ {
private const DEFAULT_API_URL = 'https://vs4sws0kg4sog4ssw8kwowk4.coolify.truckwash.dk';
private const PLATE_READER_CONFIG_JSON = '{"mode":"fast","plates_per_vehicle":1,"zoom_in_vehicles":0}';
private const RESULT_CACHE_CONTEXT = '{"config":{"mode":"fast","plates_per_vehicle":1,"zoom_in_vehicles":0},"regions":"dk,de,se,no"}';
private const PLATE_READER_REGIONS = 'dk,de,se,no';
private const DEFAULT_UPLOAD_FILE_NAME = 'license-plate.jpg';
private const RUNTIME_CONFIG_CACHE_TTL_SECONDS = 15;
private const RUNTIME_CONFIG_REDIS_CACHE_KEY = 'licenseplaterecognizer:runtime_config:v1';
private const RESULT_CACHE_TTL_SECONDS = 10;
private const RESULT_CACHE_REDIS_KEY_PREFIX = 'licenseplaterecognizer:result:v1:';
private const PLATE_READER_CONNECT_TIMEOUT_MS = 1000;
private const PLATE_READER_TOTAL_TIMEOUT_MS = 4500;
/**
* @var array<string, float>
*/
private array $last_timings = [];
/** /**
* The configuration of the module * The configuration of the module
* @var licenseplaterecognizer_c * @var licenseplaterecognizer_c
@@ -19,12 +35,25 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
* API URL * API URL
* @var string * @var string
*/ */
private string $api_url = 'https://vs4sws0kg4sog4ssw8kwowk4.coolify.truckwash.dk'; // Default (cloud): 'https://api.platerecognizer.com'; (without /v1/plate-reader/)'; private string $api_url;
/**
* @var array{enabled: bool, api_key: string}|null
*/
private ?array $runtime_config = null;
public function __construct() /**
* @var array{values: array{enabled: bool, api_key: string}, cached_at: float}|null
*/
private static ?array $runtime_config_cache = null;
public function __construct(bool $load_config = true, ?string $api_url = null)
{ {
$this->config = new licenseplaterecognizer_c(); $this->api_url = self::normalizeApiUrl($api_url ?? self::configuredApiUrl());
if ($load_config) {
$this->config = new licenseplaterecognizer_c();
}
} }
@@ -33,7 +62,7 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
*/ */
public function requireModuleEnabled(): void public function requireModuleEnabled(): void
{ {
if (!(bool)$this->config->enabled->getVariableValue()) { if (!$this->runtimeConfig()['enabled']) {
throw new Exception('licenseplaterecognizer module is not enabled.'); throw new Exception('licenseplaterecognizer module is not enabled.');
} }
} }
@@ -45,54 +74,516 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
*/ */
public function licenseplaterecognizer(string $base64_image): array public function licenseplaterecognizer(string $base64_image): array
{ {
$image_processor = new image_processor(); return $this->recognizePlate(
fn () => $this->buildPlateReaderPayload($base64_image),
//ADD PARAMETER IN REQUEST LIKE regions fn () => $this->buildResultCacheKeyFromUploadString($base64_image)
$data = array(
'upload' => $base64_image,
//'regions' => 'dk' // Optional
); );
}
// Prepare new cURL resource public function licenseplaterecognizerUpload(string $image_data, string $mime_type = 'image/jpeg'): array
//$ch = curl_init('https://api.platerecognizer.com/v1/plate-reader/'); {
$ch = curl_init($this->api_url . '/v1/plate-reader/'); return $this->recognizePlate(
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); fn () => $this->buildPlateReaderPayloadFromUpload(
curl_setopt($ch, CURLINFO_HEADER_OUT, true); $this->buildUploadValueFromBytes($image_data, $mime_type)
curl_setopt($ch, CURLOPT_POST, true); ),
curl_setopt($ch, CURLOPT_POSTFIELDS, $data); fn () => $this->buildResultCacheKeyFromBytes($image_data)
curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2TLS); );
}
// Set HTTP Header for POST request public function licenseplaterecognizerUploadUncached(string $image_data, string $mime_type = 'image/jpeg'): array
curl_setopt($ch, CURLOPT_HTTPHEADER, array( {
"Authorization: Token " . $this->config->api_key->getVariableValue() return $this->recognizePlate(
fn () => $this->buildPlateReaderPayloadFromUpload(
$this->buildUploadValueFromBytes($image_data, $mime_type)
) )
); );
}
// Submit the POST request and close cURL session handle public function licenseplaterecognizerUploadFile(string $image_path, string $mime_type = 'image/jpeg'): array
$result = curl_exec($ch); {
curl_close($ch); return $this->recognizePlate(
// Print the response from the server fn () => $this->buildPlateReaderPayloadFromUpload(
if ($result === false) { $this->buildUploadValueFromFile($image_path, $mime_type)
throw new Exception('Error in API request.'); )
} );
}
$response_data = json_decode($result, true); /**
if (isset($response_data['results']) && count($response_data['results']) > 0) { * @throws Exception
return [ */
'success' => true, private function recognizePlate(callable $payload_factory, ?callable $result_cache_key_factory = null): array
'license_plate_number' => $response_data['results'][0]['plate'] ?? null, {
'confidence' => $response_data['results'][0]['score'] ?? null, $started_at = microtime(true);
'raw_response' => $response_data, $this->last_timings = [];
$result_cache = null;
$result_cache_key = null;
try {
$config_started_at = microtime(true);
$runtime_config = $this->runtimeConfig();
if (!$runtime_config['enabled']) {
throw new Exception('licenseplaterecognizer module is not enabled.');
}
$api_key = $runtime_config['api_key'];
$this->last_timings['config'] = $this->elapsedMs($config_started_at);
if ($result_cache_key_factory !== null) {
$cache_started_at = microtime(true);
try {
$result_cache = $this->resultCacheStore();
if ($result_cache !== null) {
$result_cache_key = $result_cache_key_factory();
if ($result_cache_key !== null) {
$cached_result = $this->readRecognitionResultCache($result_cache, $result_cache_key);
if ($cached_result !== null) {
$this->last_timings['cache_hit'] = 1;
return $cached_result;
}
}
}
$this->last_timings['cache_miss'] = 1;
} finally {
$this->last_timings['cache'] = $this->elapsedMs($cache_started_at);
}
}
$payload_started_at = microtime(true);
$data = $payload_factory();
$this->last_timings['payload'] = $this->elapsedMs($payload_started_at);
$ch = curl_init($this->api_url . '/v1/plate-reader/');
if (!$ch instanceof \CurlHandle) {
throw new Exception('Error initializing API request.');
}
$curl_options = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $data,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS,
CURLOPT_CONNECTTIMEOUT_MS => self::PLATE_READER_CONNECT_TIMEOUT_MS,
CURLOPT_TIMEOUT_MS => self::PLATE_READER_TOTAL_TIMEOUT_MS,
CURLOPT_NOSIGNAL => true,
CURLOPT_NOPROGRESS => false,
CURLOPT_XFERINFOFUNCTION => self::clientDisconnectAbortCallback(),
CURLOPT_HTTPHEADER => [
"Authorization: Token " . $api_key,
'Expect:',
],
]; ];
} else { if (defined('CURLOPT_TCP_NODELAY')) {
return [ $curl_options[(int)constant('CURLOPT_TCP_NODELAY')] = true;
}
curl_setopt_array($ch, $curl_options);
// Submit the POST request and close cURL session handle
$upstream_started_at = microtime(true);
$result = curl_exec($ch);
$this->last_timings['upstream'] = $this->elapsedMs($upstream_started_at);
$this->recordCurlTimings($ch);
curl_close($ch);
// Print the response from the server
if ($result === false) {
throw new Exception('Error in API request.');
}
$parse_started_at = microtime(true);
$response_data = json_decode($result, true);
$this->last_timings['parse'] = $this->elapsedMs($parse_started_at);
$this->recordResponseTimings($response_data);
if (isset($response_data['results']) && count($response_data['results']) > 0) {
$recognized_result = [
'success' => true,
'license_plate_number' => $response_data['results'][0]['plate'] ?? null,
'confidence' => $response_data['results'][0]['score'] ?? null,
];
$this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result);
return $recognized_result;
}
$recognized_result = [
'success' => false, 'success' => false,
'message' => 'No license plate detected.', 'message' => 'No license plate detected.',
'raw_response' => $response_data,
]; ];
$this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result);
return $recognized_result;
} finally {
$this->last_timings['total'] = $this->elapsedMs($started_at);
} }
} }
private static function clientDisconnectAbortCallback(): callable
{
return static function (): int {
return connection_aborted() ? 1 : 0;
};
}
public function getLastTimings(): array
{
return $this->last_timings;
}
private function elapsedMs(float $started_at): float
{
return (microtime(true) - $started_at) * 1000;
}
private static function configuredApiUrl(): string
{
$configured = getenv('PLATE_RECOGNIZER_API_URL');
if ($configured === false || trim((string)$configured) === '') {
$configured = $_ENV['PLATE_RECOGNIZER_API_URL'] ?? $_SERVER['PLATE_RECOGNIZER_API_URL'] ?? self::DEFAULT_API_URL;
}
return (string)$configured;
}
private static function normalizeApiUrl(string $api_url): string
{
$api_url = trim($api_url);
if ($api_url === '') {
return self::DEFAULT_API_URL;
}
return rtrim($api_url, '/');
}
private function recordCurlTimings(\CurlHandle $curl_handle): void
{
$mapping = [
CURLINFO_NAMELOOKUP_TIME => 'upstream_dns',
CURLINFO_CONNECT_TIME => 'upstream_connect',
CURLINFO_APPCONNECT_TIME => 'upstream_tls',
CURLINFO_PRETRANSFER_TIME => 'upstream_pretransfer',
CURLINFO_STARTTRANSFER_TIME => 'upstream_ttfb',
CURLINFO_TOTAL_TIME => 'upstream_total',
];
foreach ($mapping as $curl_info_option => $timing_key) {
$value = curl_getinfo($curl_handle, $curl_info_option);
if (!is_numeric($value)) {
continue;
}
$this->last_timings[$timing_key] = max(0, (float)$value * 1000);
}
}
private function recordResponseTimings(mixed $response_data): void
{
if (!is_array($response_data) || !isset($response_data['processing_time']) || !is_numeric($response_data['processing_time'])) {
return;
}
$this->last_timings['upstream_processing'] = max(0, (float)$response_data['processing_time']);
}
private function buildResultCacheKeyFromUploadString(string $base64_image): string
{
$base64_image = trim($base64_image);
if (preg_match('/^data:image\/[a-zA-Z0-9.+-]+;base64,(.*)$/s', $base64_image, $matches) === 1) {
$image_data = base64_decode((string)$matches[1], true);
if (is_string($image_data)) {
return $this->buildResultCacheKeyFromBytes($image_data);
}
}
return $this->buildResultCacheKeyFromBytes($base64_image);
}
private function buildResultCacheKeyFromBytes(string $image_data): string
{
$context = hash_init('sha256');
hash_update($context, $this->resultCacheContext());
hash_update($context, "\0");
hash_update($context, $image_data);
return self::RESULT_CACHE_REDIS_KEY_PREFIX . hash_final($context);
}
private function resultCacheContext(): string
{
return self::RESULT_CACHE_CONTEXT;
}
protected function resultCacheStore(): ?object
{
return $this->runtimeConfigCacheStore();
}
private function readRecognitionResultCache(?object $cache, ?string $key): ?array
{
if ($cache === null || $key === null || !method_exists($cache, 'get')) {
return null;
}
try {
$cached = $cache->get($key);
} catch (\Throwable) {
return null;
}
if (!is_string($cached) || trim($cached) === '') {
return null;
}
$decoded = json_decode($cached, true);
if (!is_array($decoded) || !array_key_exists('success', $decoded)) {
return null;
}
return $decoded;
}
private function writeRecognitionResultCache(?object $cache, ?string $key, array $result): void
{
if ($cache === null || $key === null || !method_exists($cache, 'setEx')) {
return;
}
try {
$encoded = json_encode($result, JSON_UNESCAPED_SLASHES);
if (is_string($encoded)) {
$cache->setEx($key, $encoded, self::RESULT_CACHE_TTL_SECONDS);
}
} catch (\Throwable) {
// Scanner result cache is best-effort; Plate Recognizer remains the source of truth.
}
}
protected function buildPlateReaderPayload(string $base64_image): array
{
return $this->buildPlateReaderPayloadFromUpload($this->buildUploadValue($base64_image));
}
protected function buildPlateReaderPayloadFromUpload(string|\CURLFile|\CURLStringFile $upload): array
{
return [
'upload' => $upload,
'config' => self::PLATE_READER_CONFIG_JSON,
'regions' => self::PLATE_READER_REGIONS,
];
}
private function buildUploadValue(string $base64_image): string|\CURLStringFile
{
$base64_image = trim($base64_image);
if (preg_match('/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.*)$/s', $base64_image, $matches) !== 1) {
return $base64_image;
}
$image_data = base64_decode((string)$matches[2], true);
if ($image_data === false || !class_exists(\CURLStringFile::class)) {
return (string)$matches[2];
}
return new \CURLStringFile($image_data, self::DEFAULT_UPLOAD_FILE_NAME, (string)$matches[1]);
}
private function buildUploadValueFromBytes(string $image_data, string $mime_type): string|\CURLStringFile
{
$mime_type = trim($mime_type) !== '' ? trim($mime_type) : 'image/jpeg';
if (!str_starts_with($mime_type, 'image/')) {
$mime_type = 'image/jpeg';
}
if (!class_exists(\CURLStringFile::class)) {
return $image_data;
}
return new \CURLStringFile($image_data, self::DEFAULT_UPLOAD_FILE_NAME, $mime_type);
}
/**
* @throws Exception
*/
private function buildUploadValueFromFile(string $image_path, string $mime_type): \CURLFile
{
$image_path = trim($image_path);
$mime_type = trim($mime_type) !== '' ? trim($mime_type) : 'image/jpeg';
if (!str_starts_with($mime_type, 'image/')) {
$mime_type = 'image/jpeg';
}
if ($image_path === '' || !is_file($image_path) || !class_exists(\CURLFile::class)) {
throw new Exception('Image upload file is invalid.');
}
return new \CURLFile($image_path, $mime_type, self::DEFAULT_UPLOAD_FILE_NAME);
}
protected function runtimeConfig(): array
{
if ($this->runtime_config !== null) {
return $this->runtime_config;
}
if ($this->shouldUseSharedRuntimeConfigCache()) {
$cached_config = self::getSharedRuntimeConfigCache();
if ($cached_config !== null) {
$this->runtime_config = $cached_config;
return $this->runtime_config;
}
$cached_config = $this->readRuntimeConfigCacheStore();
if ($cached_config !== null) {
self::$runtime_config_cache = [
'values' => $cached_config,
'cached_at' => microtime(true),
];
$this->runtime_config = $cached_config;
return $this->runtime_config;
}
}
$values = $this->readRuntimeModuleConfig();
$this->runtime_config = [
'enabled' => $this->parseModuleConfigBool($values['enabled'] ?? false),
'api_key' => (string)($values['api_key'] ?? ''),
];
if ($this->shouldUseSharedRuntimeConfigCache()) {
self::$runtime_config_cache = [
'values' => $this->runtime_config,
'cached_at' => microtime(true),
];
$this->writeRuntimeConfigCacheStore($this->runtime_config);
}
return $this->runtime_config;
}
protected function shouldUseSharedRuntimeConfigCache(): bool
{
return static::class === self::class;
}
private static function getSharedRuntimeConfigCache(): ?array
{
if (self::$runtime_config_cache === null) {
return null;
}
$cache_age_seconds = microtime(true) - self::$runtime_config_cache['cached_at'];
if ($cache_age_seconds > self::RUNTIME_CONFIG_CACHE_TTL_SECONDS) {
self::$runtime_config_cache = null;
return null;
}
return self::$runtime_config_cache['values'];
}
protected function runtimeConfigCacheStore(): ?object
{
return defined('redis') ? constant('redis') : null;
}
private function readRuntimeConfigCacheStore(): ?array
{
$cache = $this->runtimeConfigCacheStore();
if ($cache === null || !method_exists($cache, 'get')) {
return null;
}
try {
$cached = $cache->get(self::RUNTIME_CONFIG_REDIS_CACHE_KEY);
} catch (\Throwable) {
return null;
}
if (!is_string($cached) || trim($cached) === '') {
return null;
}
$decoded = json_decode($cached, true);
if (!is_array($decoded)) {
return null;
}
if (!array_key_exists('enabled', $decoded) || !array_key_exists('api_key', $decoded)) {
return null;
}
return [
'enabled' => $this->parseModuleConfigBool($decoded['enabled']),
'api_key' => (string)$decoded['api_key'],
];
}
/**
* @param array{enabled: bool, api_key: string} $config
*/
private function writeRuntimeConfigCacheStore(array $config): void
{
$cache = $this->runtimeConfigCacheStore();
if ($cache === null || !method_exists($cache, 'setEx')) {
return;
}
try {
$encoded = json_encode($config, JSON_UNESCAPED_SLASHES);
if (is_string($encoded)) {
$cache->setEx(self::RUNTIME_CONFIG_REDIS_CACHE_KEY, $encoded, self::RUNTIME_CONFIG_CACHE_TTL_SECONDS);
}
} catch (\Throwable) {
// Scanner config cache is best-effort; DB remains the source of truth.
}
}
private function parseModuleConfigBool(mixed $value): bool
{
if (is_bool($value)) {
return $value;
}
if (is_numeric($value)) {
return (int)$value === 1;
}
return strtolower(trim((string)$value)) === 'true';
}
protected function readRuntimeModuleConfig(): array
{
global $db;
if ($db instanceof db) {
$module = $db->escape_string('licenseplaterecognizer');
$result = $db->query("SELECT variable, value FROM module_config WHERE module = '$module' AND variable IN ('enabled', 'api_key')");
$values = [];
if ($result instanceof \mysqli_result) {
while ($row = $result->fetch_assoc()) {
$variable = (string)($row['variable'] ?? '');
if ($variable !== '') {
$values[$variable] = (string)($row['value'] ?? '');
}
}
}
return $values;
}
if (!isset($this->config)) {
$this->config = new licenseplaterecognizer_c();
}
return [
'enabled' => (string)$this->config->enabled->getVariableValue(),
'api_key' => (string)$this->config->api_key->getVariableValue(),
];
}
/** /**
* @inheritDoc * @inheritDoc
* @throws Exception If the module is not enabled or if there is an error in the API request * @throws Exception If the module is not enabled or if there is an error in the API request
@@ -100,8 +591,8 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
*/ */
public function get_usage(): licenseplaterecognizer_info public function get_usage(): licenseplaterecognizer_info
{ {
// Require the module to be enabled
$this->requireModuleEnabled(); $this->requireModuleEnabled();
$api_key = $this->runtimeConfig()['api_key'];
$curl = curl_init(); $curl = curl_init();
curl_setopt_array($curl, array( curl_setopt_array($curl, array(
CURLOPT_URL => $this->api_url . '/info/', CURLOPT_URL => $this->api_url . '/info/',
@@ -112,9 +603,9 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
CURLOPT_FOLLOWLOCATION => true, CURLOPT_FOLLOWLOCATION => true,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS, CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS,
CURLOPT_CUSTOMREQUEST => 'GET', CURLOPT_CUSTOMREQUEST => 'GET',
CURLOPT_HTTPHEADER => array( CURLOPT_HTTPHEADER => [
'Authorization: Token ' . $this->config->api_key->getVariableValue() 'Authorization: Token ' . $api_key,
), ],
)); ));
$response = curl_exec($curl); $response = curl_exec($curl);
curl_close($curl); curl_close($curl);
@@ -124,4 +615,4 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
} }
return new licenseplaterecognizer_info($response_data); return new licenseplaterecognizer_info($response_data);
} }
} }
@@ -0,0 +1,24 @@
<?php
namespace classes;
class limited_backoffice_exception extends \RuntimeException
{
public function __construct(
string $message,
private readonly int $statusCode = 400,
private readonly ?array $payload = null
) {
parent::__construct($message);
}
public function statusCode(): int
{
return $this->statusCode;
}
public function payload(): array|string
{
return $this->payload ?? $this->getMessage();
}
}
@@ -0,0 +1,43 @@
<?php
namespace classes;
class limited_backoffice_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
$db->query(<<<'SQL'
CREATE TABLE IF NOT EXISTS `limited_backoffice_employees` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` INT NOT NULL,
`managed_group_id` INT NOT NULL,
`role_key` VARCHAR(64) NOT NULL,
`department_ids` LONGTEXT NOT NULL,
`created_by_user_id` INT NOT NULL,
`updated_by_user_id` INT NULL,
`deactivated_at` DATETIME NULL,
`created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_limited_backoffice_employees_user_id` (`user_id`),
KEY `idx_limited_backoffice_employees_group_id` (`managed_group_id`),
KEY `idx_limited_backoffice_employees_role_key` (`role_key`),
KEY `idx_limited_backoffice_employees_deactivated_at` (`deactivated_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
SQL);
self::$initialized = true;
}
}
File diff suppressed because it is too large Load Diff
+35 -4
View File
@@ -340,18 +340,49 @@ class n8n implements n8n_i
throw new Exception('Webhook target must not be empty.'); throw new Exception('Webhook target must not be empty.');
} }
if (filter_var($target, FILTER_VALIDATE_URL) !== false) {
return $target;
}
$baseUrl = trim((string)$this->config->webhook_base_url->getVariableValue()); $baseUrl = trim((string)$this->config->webhook_base_url->getVariableValue());
if ($baseUrl === '') { if ($baseUrl === '') {
throw new Exception('n8n webhook base URL is not configured.'); throw new Exception('n8n webhook base URL is not configured.');
} }
if (filter_var($target, FILTER_VALIDATE_URL) !== false) {
if (!$this->isAllowedWebhookAbsoluteUrl($target, $baseUrl)) {
throw new Exception('Webhook URL must use the configured n8n webhook host.');
}
return $target;
}
return rtrim($baseUrl, '/') . '/' . ltrim($target, '/'); return rtrim($baseUrl, '/') . '/' . ltrim($target, '/');
} }
private function isAllowedWebhookAbsoluteUrl(string $targetUrl, string $baseUrl): bool
{
$targetParts = parse_url($targetUrl);
$baseParts = parse_url($baseUrl);
if (!is_array($targetParts) || !is_array($baseParts)) {
return false;
}
$targetHost = strtolower((string)($targetParts['host'] ?? ''));
$baseHost = strtolower((string)($baseParts['host'] ?? ''));
if ($targetHost === '' || $baseHost === '' || $targetHost !== $baseHost) {
return false;
}
$targetScheme = strtolower((string)($targetParts['scheme'] ?? ''));
$baseScheme = strtolower((string)($baseParts['scheme'] ?? ''));
if ($targetScheme === '' || $baseScheme === '' || $targetScheme !== $baseScheme) {
return false;
}
$targetPort = (int)($targetParts['port'] ?? ($targetScheme === 'https' ? 443 : 80));
$basePort = (int)($baseParts['port'] ?? ($baseScheme === 'https' ? 443 : 80));
return $targetPort === $basePort;
}
/** /**
* @throws Exception * @throws Exception
*/ */
@@ -46,7 +46,7 @@ class order_reference_suggestions_service
$rows = [ $rows = [
...$this->fetchBookingRows($departmentId, $search), ...$this->fetchBookingRows($departmentId, $search),
...$this->fetchOrderRows($departmentId, $search), ...$this->fetchOrderRows($departmentId, $search),
...$this->fetchVehicleRows($customerId, $plates, $search), ...$this->fetchVehicleRows($departmentId, $customerId, $plates, $search),
]; ];
$suggestions = $this->aggregateRows($rows, $search, $customerId, $plates); $suggestions = $this->aggregateRows($rows, $search, $customerId, $plates);
@@ -137,7 +137,7 @@ class order_reference_suggestions_service
* @param array<int, string> $plates * @param array<int, string> $plates
* @return array<int, array<string, mixed>> * @return array<int, array<string, mixed>>
*/ */
private function fetchVehicleRows(?int $customerId, array $plates, string $search): array private function fetchVehicleRows(int $departmentId, ?int $customerId, array $plates, string $search): array
{ {
$contextWhere = []; $contextWhere = [];
$params = []; $params = [];
@@ -171,6 +171,10 @@ class order_reference_suggestions_service
$params['search'] = '%' . $this->lower($search) . '%'; $params['search'] = '%' . $this->lower($search) . '%';
} }
$where[] = $this->vehicleDepartmentAccessPredicate();
$params['orders_department_id'] = $departmentId;
$params['bookings_department_id'] = $departmentId;
$sql = "SELECT $sql = "SELECT
'vehicle' AS source, 'vehicle' AS source,
id AS origin_id, id AS origin_id,
@@ -190,6 +194,41 @@ class order_reference_suggestions_service
return $this->fetchRows($sql, $params); return $this->fetchRows($sql, $params);
} }
private function vehicleDepartmentAccessPredicate(): string
{
$ordersWhere = [
'authorized_orders.department_id = :orders_department_id',
'(authorized_orders.customer_id = customer_vehicles.customer_id'
. " OR UPPER(REPLACE(authorized_orders.reg_1, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', ''))"
. " OR UPPER(REPLACE(authorized_orders.reg_2, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', ''))"
. " OR UPPER(REPLACE(authorized_orders.reg_3, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', '')))"
];
if ($this->tableHasColumn('orders', 'deleted_at')) {
$ordersWhere[] = 'authorized_orders.deleted_at IS NULL';
}
$bookingsWhere = [
'authorized_bookings.department = :bookings_department_id',
'(authorized_bookings.customer_number = customer_vehicles.customer_id'
. " OR UPPER(REPLACE(authorized_bookings.reg_1, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', ''))"
. " OR UPPER(REPLACE(authorized_bookings.reg_2, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', ''))"
. " OR UPPER(REPLACE(authorized_bookings.reg_3, ' ', '')) = UPPER(REPLACE(customer_vehicles.reg, ' ', '')))"
];
if ($this->tableHasColumn('order_bookings', 'deleted_at')) {
$bookingsWhere[] = 'authorized_bookings.deleted_at IS NULL';
}
return '(EXISTS (
SELECT 1
FROM orders authorized_orders
WHERE ' . implode(' AND ', $ordersWhere) . '
) OR EXISTS (
SELECT 1
FROM order_bookings authorized_bookings
WHERE ' . implode(' AND ', $bookingsWhere) . '
))';
}
/** /**
* @param array<string, mixed> $params * @param array<string, mixed> $params
* @return array<int, array<string, mixed>> * @return array<int, array<string, mixed>>
@@ -58,6 +58,9 @@ class orders_schema_bootstrap
|| !self::columnExists($db, 'orders', 'booking_id') || !self::columnExists($db, 'orders', 'booking_id')
|| !self::columnExists($db, 'orders', 'po') || !self::columnExists($db, 'orders', 'po')
|| !self::columnExists($db, 'order_bookings', 'po') || !self::columnExists($db, 'order_bookings', 'po')
|| !self::columnExists($db, 'order_bookings', 'customer_number')
|| !self::columnExists($db, 'order_bookings', 'department')
|| !self::columnExists($db, 'order_bookings', 'deleted_at')
) { ) {
return; return;
} }
@@ -65,6 +68,9 @@ class orders_schema_bootstrap
$db->query( $db->query(
"UPDATE orders o "UPDATE orders o
INNER JOIN order_bookings b ON b.id = o.booking_id INNER JOIN order_bookings b ON b.id = o.booking_id
AND b.customer_number = o.customer_id
AND b.department = o.department_id
AND b.deleted_at IS NULL
SET o.po = b.po SET o.po = b.po
WHERE o.booking_id IS NOT NULL WHERE o.booking_id IS NOT NULL
AND o.booking_id > 0 AND o.booking_id > 0
+5 -1
View File
@@ -47,8 +47,12 @@ class pdf_store implements minio_pdfs_i
*/ */
public function download(string $file): string public function download(string $file): string
{ {
if ($this->shouldUseLocalTestStorage()) {
return $this->getLocalTestObjectPath($file);
}
$path = '/tmp/' . $file; $path = '/tmp/' . $file;
$result = self::getS3Client()->getObject([ self::getS3Client()->getObject([
'Bucket' => self::getBucket(), 'Bucket' => self::getBucket(),
'Key' => $file, 'Key' => $file,
'SaveAs' => $path 'SaveAs' => $path
+18 -2
View File
@@ -392,7 +392,19 @@ class redis implements redis_i
private function invoicePeriodCacheKey(string $prefix, string $dateFrom, string $dateTo): string private function invoicePeriodCacheKey(string $prefix, string $dateFrom, string $dateTo): string
{ {
return $prefix . ':' . $dateFrom . ':' . $dateTo; return $prefix . ':'
. $this->normalizeInvoicePeriodCacheDate($dateFrom, true) . ':'
. $this->normalizeInvoicePeriodCacheDate($dateTo, false);
}
private function normalizeInvoicePeriodCacheDate(string $date, bool $startOfDay): string
{
$timestamp = strtotime($date);
if ($timestamp === false) {
return $date;
}
return date($startOfDay ? 'Y-m-d 00:00:00' : 'Y-m-d 23:59:59', $timestamp);
} }
private function workfeedEmployeeNameCacheKey(string $employeeId): string private function workfeedEmployeeNameCacheKey(string $employeeId): string
@@ -512,7 +524,11 @@ class redis implements redis_i
*/ */
public function enqueue_invoice_period_warming(string $dateFrom, string $dateTo): self public function enqueue_invoice_period_warming(string $dateFrom, string $dateTo): self
{ {
$this->get_client()->sadd('invoice_period_warming_queue', [$dateFrom . '|' . $dateTo]); $this->get_client()->sadd('invoice_period_warming_queue', [
$this->normalizeInvoicePeriodCacheDate($dateFrom, true)
. '|'
. $this->normalizeInvoicePeriodCacheDate($dateTo, false),
]);
return $this; return $this;
} }
File diff suppressed because it is too large Load Diff
@@ -118,6 +118,35 @@ class release_manager_schema_bootstrap
INDEX idx_release_targets_coolify (coolify_instance_id, coolify_service_uuid) INDEX idx_release_targets_coolify (coolify_instance_id, coolify_service_uuid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_auto_sync_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NOT NULL,
app VARCHAR(16) NOT NULL,
repository VARCHAR(255) NOT NULL,
branch VARCHAR(128) NOT NULL,
commit_sha VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
source VARCHAR(64) NULL,
workflow_url VARCHAR(512) NULL,
gate_operation_id BIGINT UNSIGNED NULL,
sync_operation_id BIGINT UNSIGNED NULL,
deployment_id BIGINT UNSIGNED NULL,
error_message TEXT NULL,
metadata_json LONGTEXT NULL,
received_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
gate_passed_at DATETIME NULL,
synced_at DATETIME NULL,
promoted_at DATETIME NULL,
failed_at DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_release_auto_sync_event (channel_id, app, repository, branch, commit_sha),
INDEX idx_release_auto_sync_channel_status (channel_id, status, updated_at),
INDEX idx_release_auto_sync_gate (gate_operation_id),
INDEX idx_release_auto_sync_sync (sync_operation_id),
INDEX idx_release_auto_sync_deployment (deployment_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS release_service_sets ( "CREATE TABLE IF NOT EXISTS release_service_sets (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
channel_id BIGINT UNSIGNED NULL, channel_id BIGINT UNSIGNED NULL,
@@ -408,6 +437,7 @@ class release_manager_schema_bootstrap
'release_versions', 'release_versions',
'release_channel_versions', 'release_channel_versions',
'release_assignments', 'release_assignments',
'release_auto_sync_events',
'release_service_sets', 'release_service_sets',
'release_deployments', 'release_deployments',
'release_bundles', 'release_bundles',
+1 -1
View File
@@ -89,7 +89,7 @@ class router
} }
// Regex // Regex
$route = str_replace('/', '\/', $route); $route = str_replace('/', '\/', $route);
$route = preg_replace('/{[a-zA-Z0-9]+}/', '([a-zA-Z0-9]+)', $route); $route = preg_replace('/{[a-zA-Z0-9_]+}/', '([a-zA-Z0-9]+)', $route);
if (preg_match('/^' . $route . '$/', $this->url)) { if (preg_match('/^' . $route . '$/', $this->url)) {
return true; return true;
} }
@@ -95,7 +95,7 @@ class selfserve_schema_bootstrap
session_id INT NOT NULL, session_id INT NOT NULL,
task_id INT NULL, task_id INT NULL,
task_text VARCHAR(255) NOT NULL, task_text VARCHAR(255) NOT NULL,
description VARCHAR(255) NULL, description TEXT NULL,
services JSON NULL, services JSON NULL,
buttons JSON NULL, buttons JSON NULL,
dynamic_image_id INT NULL, dynamic_image_id INT NULL,
@@ -139,6 +139,28 @@ class selfserve_schema_bootstrap
UNIQUE KEY uniq_selfserve_vhw_department (department_id), UNIQUE KEY uniq_selfserve_vhw_department (department_id),
INDEX idx_selfserve_vhw_dept_updated (department_id, updated_at) INDEX idx_selfserve_vhw_dept_updated (department_id, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS department_selfserve_path_confirmations (
id INT AUTO_INCREMENT PRIMARY KEY,
department_id INT NOT NULL,
lane_id INT NULL,
vehicle_type_id INT NULL,
config_version_id INT NULL,
config_source VARCHAR(32) NOT NULL DEFAULT 'draft',
path_signature VARCHAR(128) NOT NULL,
result_signature VARCHAR(128) NOT NULL,
answers_json JSON NOT NULL,
result_json JSON NOT NULL,
scope_json JSON NULL,
confirmed_by INT NULL,
confirmed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
stale_reason VARCHAR(255) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
INDEX idx_selfserve_path_conf_department_scope (department_id, lane_id, vehicle_type_id, config_version_id),
INDEX idx_selfserve_path_conf_signature (department_id, config_version_id, path_signature)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
]; ];
foreach ($queries as $sql) { foreach ($queries as $sql) {
@@ -175,6 +197,18 @@ class selfserve_schema_bootstrap
'gate_ref_id', 'gate_ref_id',
'ALTER TABLE department_selfserve_tasks ADD COLUMN gate_ref_id INT NULL AFTER gate_type' 'ALTER TABLE department_selfserve_tasks ADD COLUMN gate_ref_id INT NULL AFTER gate_type'
); );
self::ensureColumnDataType(
'department_selfserve_tasks',
'description',
['text', 'mediumtext', 'longtext'],
'ALTER TABLE department_selfserve_tasks MODIFY COLUMN description TEXT NULL AFTER task'
);
self::ensureColumnDataType(
'selfserve_wash_session_tasks',
'description',
['text', 'mediumtext', 'longtext'],
'ALTER TABLE selfserve_wash_session_tasks MODIFY COLUMN description TEXT NULL AFTER task_text'
);
self::ensureColumn( self::ensureColumn(
'selfserve_wash_session_tasks', 'selfserve_wash_session_tasks',
'dynamic_images_vehicle_type', 'dynamic_images_vehicle_type',
@@ -217,4 +251,49 @@ class selfserve_schema_bootstrap
} }
$db->query($alterSql); $db->query($alterSql);
} }
/**
* @param array<int,string> $acceptedDataTypes
*/
public static function ensureColumnDataType(string $table, string $column, array $acceptedDataTypes, string $alterSql): void
{
global $db;
$columnInfo = self::columnInfo($table, $column);
if ($columnInfo === null) {
return;
}
$dataType = strtolower((string)($columnInfo['DATA_TYPE'] ?? ''));
$acceptedDataTypes = array_map(static fn(string $type): string => strtolower($type), $acceptedDataTypes);
if (in_array($dataType, $acceptedDataTypes, true)) {
return;
}
$db->query($alterSql);
}
/**
* @return array<string,mixed>|null
*/
public static function columnInfo(string $table, string $column): ?array
{
global $db;
$table = $db->escape_string($table);
$column = $db->escape_string($column);
$database = $db->escape_string($db->getDatabase());
$sql = "SELECT DATA_TYPE, COLUMN_TYPE, IS_NULLABLE, CHARACTER_MAXIMUM_LENGTH
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = '$database'
AND TABLE_NAME = '$table'
AND COLUMN_NAME = '$column'
LIMIT 1";
$result = $db->query($sql);
if (!$result) {
return null;
}
$row = $result->fetch_assoc();
return is_array($row) ? $row : null;
}
} }
+8
View File
@@ -16,6 +16,8 @@ class shelly implements shelly_i
private const SHELLY_RATE_LIMIT_WAIT_TIMEOUT_SECONDS = 20; private const SHELLY_RATE_LIMIT_WAIT_TIMEOUT_SECONDS = 20;
private const SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS = 2000; private const SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS = 2000;
private const SHELLY_RATE_LIMIT_GATE_KEY = 'shelly_cloud_rate_limit_gate'; private const SHELLY_RATE_LIMIT_GATE_KEY = 'shelly_cloud_rate_limit_gate';
private const SHELLY_CONNECT_TIMEOUT_SECONDS = 2;
private const SHELLY_REQUEST_TIMEOUT_SECONDS = 5;
/** /**
* @var array<int,array<string,mixed>> * @var array<int,array<string,mixed>>
*/ */
@@ -178,6 +180,9 @@ class shelly implements shelly_i
curl_setopt($ch, CURLOPT_HTTPHEADER, [ curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json', 'Content-Type: application/json',
]); ]);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, self::SHELLY_CONNECT_TIMEOUT_SECONDS);
curl_setopt($ch, CURLOPT_TIMEOUT, self::SHELLY_REQUEST_TIMEOUT_SECONDS);
curl_setopt($ch, CURLOPT_NOSIGNAL, true);
// Execute the request // Execute the request
$response = curl_exec($ch); $response = curl_exec($ch);
// Get the status code // Get the status code
@@ -224,6 +229,9 @@ class shelly implements shelly_i
); );
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPGET, true); curl_setopt($ch, CURLOPT_HTTPGET, true);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, self::SHELLY_CONNECT_TIMEOUT_SECONDS);
curl_setopt($ch, CURLOPT_TIMEOUT, self::SHELLY_REQUEST_TIMEOUT_SECONDS);
curl_setopt($ch, CURLOPT_NOSIGNAL, true);
$response = curl_exec($ch); $response = curl_exec($ch);
$status_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $status_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
@@ -33,7 +33,7 @@ class shelly_relay_inventory
* @return array<int,array<string,mixed>> * @return array<int,array<string,mixed>>
* @throws Exception * @throws Exception
*/ */
public function listRelayOptions(): array public function listRelayOptions(bool $include_sensitive_network_details = false): array
{ {
$devices_status = $this->fetchOwnedDevicesStatus(); $devices_status = $this->fetchOwnedDevicesStatus();
$device_catalog = $this->fetchOwnedDeviceCatalog(); $device_catalog = $this->fetchOwnedDeviceCatalog();
@@ -49,7 +49,8 @@ class shelly_relay_inventory
$option = $this->buildRelayOption( $option = $this->buildRelayOption(
$normalized_device, $normalized_device,
is_array($catalog_entry) ? $catalog_entry : null is_array($catalog_entry) ? $catalog_entry : null,
$include_sensitive_network_details
); );
if ($option === null) { if ($option === null) {
continue; continue;
@@ -160,7 +161,11 @@ class shelly_relay_inventory
* @param array<string,mixed> $device * @param array<string,mixed> $device
* @return array<string,mixed>|null * @return array<string,mixed>|null
*/ */
private function buildRelayOption(array $device, ?array $catalog_entry = null): ?array private function buildRelayOption(
array $device,
?array $catalog_entry = null,
bool $include_sensitive_network_details = false
): ?array
{ {
if ($device === [] || !$this->isRelayCapableDevice($device)) { if ($device === [] || !$this->isRelayCapableDevice($device)) {
return null; return null;
@@ -203,9 +208,8 @@ class shelly_relay_inventory
$online = $this->normalizeBoolean($catalog_entry['cloud_online'] ?? null); $online = $this->normalizeBoolean($catalog_entry['cloud_online'] ?? null);
} }
$status_color = $this->extractStatusColor($online); $status_color = $this->extractStatusColor($online);
$local_ip = $this->extractLocalIp($device, $catalog_entry);
return [ $option = [
'id' => $device_id, 'id' => $device_id,
'name' => $this->buildRelayLabel( 'name' => $this->buildRelayLabel(
$device_type, $device_type,
@@ -223,10 +227,15 @@ class shelly_relay_inventory
'device_generation' => $device_generation, 'device_generation' => $device_generation,
'control_type' => $control_type, 'control_type' => $control_type,
'control_name' => $control_name !== '' ? $control_name : null, 'control_name' => $control_name !== '' ? $control_name : null,
'local_ip' => $local_ip,
'status_color' => $status_color, 'status_color' => $status_color,
'online' => $online, 'online' => $online,
]; ];
if ($include_sensitive_network_details) {
$option['local_ip'] = $this->extractLocalIp($device, $catalog_entry);
}
return $option;
} }
/** /**
+199 -2
View File
@@ -6,13 +6,25 @@ use GuzzleHttp\Client;
use interfaces\notification_i; use interfaces\notification_i;
use objects\departments_o; use objects\departments_o;
use objects\users_o; use objects\users_o;
use slack\slack_c;
use traits\notification_t; use traits\notification_t;
require_once WD . '/modules/slack/slack_c.php';
class slack implements notification_i class slack implements notification_i
{ {
use notification_t; use notification_t;
private ?slack_c $config = null;
public function getConfig(): slack_c
{
if ($this->config === null) {
$this->config = new slack_c();
}
return $this->config;
}
/** /**
* @inheritdoc * @inheritdoc
@@ -122,7 +134,7 @@ class slack implements notification_i
. "Status: $status"; . "Status: $status";
} }
public function send_message(string $string, string $module = null): void public function send_message(string $string, ?string $module = null): void
{ {
global $SLACK_DEFAULT_WEBHOOK; global $SLACK_DEFAULT_WEBHOOK;
// Format the message if a module is provided // Format the message if a module is provided
@@ -132,4 +144,189 @@ class slack implements notification_i
// Send the message to the slack webhook // Send the message to the slack webhook
self::add_log(self::send_webhook_message($string, $SLACK_DEFAULT_WEBHOOK)); self::add_log(self::send_webhook_message($string, $SLACK_DEFAULT_WEBHOOK));
} }
}
public function send_customer_registration_notification(int $customer_number): self
{
$webhook = $this->get_customer_registration_webhook_url();
if ($webhook === '') {
return $this;
}
self::add_log(self::send_webhook_message(
$this->format_customer_registration($customer_number),
$webhook
));
return $this;
}
/**
* Send a sanitized customer-registration test notification to the saved Slack webhook.
*
* @return array{configured:bool,sent:bool,message:string}
*/
public function test_customer_registration_webhook(): array
{
$webhook = $this->get_customer_registration_webhook_url();
if ($webhook === '') {
return [
'configured' => false,
'sent' => false,
'message' => 'Slack customer registration webhook URL is not configured.',
];
}
$result = $this->send_webhook_message(
$this->format_customer_registration_test(),
$webhook
);
$sent = $this->is_webhook_send_successful($result);
self::add_log($sent
? 'Slack customer registration test webhook sent successfully.'
: 'Slack customer registration test webhook failed.'
);
return [
'configured' => true,
'sent' => $sent,
'message' => $sent
? 'Slack test message sent successfully.'
: 'Slack test message failed.',
];
}
/**
* Send a sanitized internal department goal progress test notification to the saved Slack webhook.
*
* @return array{configured:bool,sent:bool,message:string}
*/
public function test_internal_department_goal_progress_webhook(): array
{
$webhook = $this->get_internal_department_goal_progress_webhook_url();
if ($webhook === '') {
return [
'configured' => false,
'sent' => false,
'message' => 'Slack internal department goal progress webhook URL is not configured.',
];
}
$result = $this->send_webhook_message(
$this->format_internal_department_goal_progress_test(),
$webhook
);
$sent = $this->is_webhook_send_successful($result);
self::add_log($sent
? 'Slack internal department goal progress test webhook sent successfully.'
: 'Slack internal department goal progress test webhook failed.'
);
return [
'configured' => true,
'sent' => $sent,
'message' => $sent
? 'Slack test message sent successfully.'
: 'Slack test message failed.',
];
}
protected function get_customer_registration_webhook_url(): string
{
return trim((string)$this->getConfig()->customer_registration_webhook_url->getVariableValue());
}
public function get_internal_department_goal_progress_webhook_url(): string
{
return trim((string)$this->getConfig()->internal_department_goal_progress_webhook_url->getVariableValue());
}
/**
* @return int[]
*/
public function get_internal_department_ids(): array
{
return $this->getConfig()->internal_department_ids->getDepartmentIds();
}
/**
* @param int[] $department_ids
* @throws \Exception
*/
public function set_internal_department_goal_progress_config(string $webhook_url, array $department_ids): array
{
$this->getConfig()->internal_department_goal_progress_webhook_url->setVariableValue(trim($webhook_url));
$this->getConfig()->internal_department_ids->setVariableValue($department_ids);
return $this->get_internal_department_goal_progress_config();
}
public function get_internal_department_goal_progress_config(): array
{
$departments = (new departments_o())->getFieldsWhere(
[
'visible' => 1,
'archived' => 0,
],
[
'id',
'name',
'order_priority',
]
);
usort($departments, static function (array $a, array $b): int {
return (int)($a['order_priority'] ?? 0) <=> (int)($b['order_priority'] ?? 0)
?: (int)($a['id'] ?? 0) <=> (int)($b['id'] ?? 0);
});
return [
'internal_department_goal_progress_webhook_url' => $this->get_internal_department_goal_progress_webhook_url(),
'internal_department_ids' => $this->get_internal_department_ids(),
'departments' => array_map(static function (array $department): array {
return [
'id' => (int)$department['id'],
'name' => (string)$department['name'],
'order_priority' => (int)$department['order_priority'],
];
}, $departments),
];
}
public function is_webhook_send_successful(string $result): bool
{
return !str_starts_with($result, 'Failed to send message:');
}
public function format_customer_registration(int $customer_number): string
{
$customer = (new users_o())->getUserByCustomerNumber($customer_number);
$customerName = $customer->exists()
? $customer->getCustomerName((int)$customer->customer_number->value())
: '';
$customerName = trim((string)$customerName);
if ($customerName === '') {
$customerName = 'Unknown customer';
}
$safeCustomerNumber = (int)$customer_number;
$customerUrl = 'https://truckwash.io/superuser/users?search=' . $safeCustomerNumber;
return "*New customer registered on Truck Wash*\n"
. "Customer: $customerName ($safeCustomerNumber)\n"
. "Open in Superuser: $customerUrl";
}
public function format_customer_registration_test(): string
{
return "*Truck Wash Slack test*\n"
. "Customer registration notifications are configured correctly.";
}
public function format_internal_department_goal_progress_test(): string
{
return "*Truck Wash Slack test*\n"
. "Internal department goal progress notifications are configured correctly.";
}
}
@@ -54,6 +54,7 @@ class system_search_service
$allowedTypes = $this->normalizeTypes((array)($options['allowed_types'] ?? [])); $allowedTypes = $this->normalizeTypes((array)($options['allowed_types'] ?? []));
$ownOnlyTypes = $this->normalizeTypes((array)($options['own_only_types'] ?? [])); $ownOnlyTypes = $this->normalizeTypes((array)($options['own_only_types'] ?? []));
$ownCustomerNumber = isset($options['own_customer_number']) ? (int)$options['own_customer_number'] : null; $ownCustomerNumber = isset($options['own_customer_number']) ? (int)$options['own_customer_number'] : null;
$allowedDepartmentIds = array_values(array_unique(array_map('intval', (array)($options['allowed_department_ids'] ?? []))));
$permissionsCatalogAll = (array)($options['permissions_catalog_all'] ?? []); $permissionsCatalogAll = (array)($options['permissions_catalog_all'] ?? []);
$permissionsCatalogOwn = (array)($options['permissions_catalog_own'] ?? []); $permissionsCatalogOwn = (array)($options['permissions_catalog_own'] ?? []);
$moduleConfigVisibility = (array)($options['module_config_visibility'] ?? []); $moduleConfigVisibility = (array)($options['module_config_visibility'] ?? []);
@@ -107,6 +108,7 @@ class system_search_service
'offset' => $offset, 'offset' => $offset,
'own' => $ownCustomerNumber, 'own' => $ownCustomerNumber,
'own_only' => $ownOnlyTypes, 'own_only' => $ownOnlyTypes,
'dept' => $allowedDepartmentIds,
'assoc' => $includeAssociations, 'assoc' => $includeAssociations,
'dbg' => $debugIntent, 'dbg' => $debugIntent,
'ctx' => $this->permissionContextFingerprint($permissionsCatalogAll, $permissionsCatalogOwn, $moduleConfigVisibility), 'ctx' => $this->permissionContextFingerprint($permissionsCatalogAll, $permissionsCatalogOwn, $moduleConfigVisibility),
@@ -130,7 +132,8 @@ class system_search_service
$ownCustomerNumber, $ownCustomerNumber,
$permissionsCatalogAll, $permissionsCatalogAll,
$permissionsCatalogOwn, $permissionsCatalogOwn,
$moduleConfigVisibility $moduleConfigVisibility,
$allowedDepartmentIds
); );
$intentAssociationHint = false; $intentAssociationHint = false;
@@ -180,7 +183,8 @@ class system_search_service
$ownCustomerNumber, $ownCustomerNumber,
$permissionsCatalogAll, $permissionsCatalogAll,
$permissionsCatalogOwn, $permissionsCatalogOwn,
$moduleConfigVisibility $moduleConfigVisibility,
$allowedDepartmentIds
); );
} else { } else {
$intentMeta['status'] = 'fallback'; $intentMeta['status'] = 'fallback';
@@ -207,25 +211,29 @@ class system_search_service
$activeTypes, $activeTypes,
$this->associationEntityTypes() $this->associationEntityTypes()
)); ));
foreach ($customerNumbers as $customerNumber) { $associationTypes = array_values(array_diff($associationTypes, $ownOnlyTypes));
$associated = $this->executeLexicalSearch( if (!empty($associationTypes)) {
$associationTypes, foreach ($customerNumbers as $customerNumber) {
[(string)$customerNumber], $associated = $this->executeLexicalSearch(
[], $associationTypes,
$ownOnlyTypes, [(string)$customerNumber],
$ownCustomerNumber, [],
$permissionsCatalogAll, $ownOnlyTypes,
$permissionsCatalogOwn, $ownCustomerNumber,
$moduleConfigVisibility, $permissionsCatalogAll,
[$customerNumber] $permissionsCatalogOwn,
); $moduleConfigVisibility,
foreach ($associated as &$item) { $allowedDepartmentIds,
if (!isset($item['association_reason'])) { [$customerNumber]
$item['association_reason'] = 'customer:' . $customerNumber; );
foreach ($associated as &$item) {
if (!isset($item['association_reason'])) {
$item['association_reason'] = 'customer:' . $customerNumber;
}
$item['score'] = max((int)$item['score'], 35);
} }
$item['score'] = max((int)$item['score'], 35); $initialResults = $this->mergeResults($initialResults, $associated);
} }
$initialResults = $this->mergeResults($initialResults, $associated);
} }
} }
} }
@@ -304,6 +312,7 @@ class system_search_service
* @param array<string, string> $permissionsCatalogAll * @param array<string, string> $permissionsCatalogAll
* @param array<int, string> $permissionsCatalogOwn * @param array<int, string> $permissionsCatalogOwn
* @param array<string, bool> $moduleConfigVisibility * @param array<string, bool> $moduleConfigVisibility
* @param array<int, int> $allowedDepartmentIds
* @param array<int, int> $forcedCustomerNumbers * @param array<int, int> $forcedCustomerNumbers
* @return array<int, array<string, mixed>> * @return array<int, array<string, mixed>>
*/ */
@@ -316,6 +325,7 @@ class system_search_service
array $permissionsCatalogAll, array $permissionsCatalogAll,
array $permissionsCatalogOwn, array $permissionsCatalogOwn,
array $moduleConfigVisibility, array $moduleConfigVisibility,
array $allowedDepartmentIds = [],
array $forcedCustomerNumbers = [] array $forcedCustomerNumbers = []
): array { ): array {
$results = []; $results = [];
@@ -334,6 +344,7 @@ class system_search_service
$ownOnly, $ownOnly,
$ownCustomerNumber, $ownCustomerNumber,
$moduleConfigVisibility, $moduleConfigVisibility,
$allowedDepartmentIds,
$forcedCustomerNumbers $forcedCustomerNumbers
); );
if (empty($rows)) { if (empty($rows)) {
@@ -346,6 +357,7 @@ class system_search_service
$permissionsCatalogAll, $permissionsCatalogAll,
$permissionsCatalogOwn, $permissionsCatalogOwn,
$moduleConfigVisibility, $moduleConfigVisibility,
$allowedDepartmentIds,
$forcedCustomerNumbers $forcedCustomerNumbers
); );
} }
@@ -359,6 +371,7 @@ class system_search_service
$permissionsCatalogAll, $permissionsCatalogAll,
$permissionsCatalogOwn, $permissionsCatalogOwn,
$moduleConfigVisibility, $moduleConfigVisibility,
$allowedDepartmentIds,
$forcedCustomerNumbers $forcedCustomerNumbers
); );
} }
@@ -372,6 +385,7 @@ class system_search_service
* @param array<string, string> $permissionsCatalogAll * @param array<string, string> $permissionsCatalogAll
* @param array<int, string> $permissionsCatalogOwn * @param array<int, string> $permissionsCatalogOwn
* @param array<string, bool> $moduleConfigVisibility * @param array<string, bool> $moduleConfigVisibility
* @param array<int, int> $allowedDepartmentIds
* @param array<int, int> $forcedCustomerNumbers * @param array<int, int> $forcedCustomerNumbers
* @return array<int, array<string, mixed>> * @return array<int, array<string, mixed>>
*/ */
@@ -384,6 +398,7 @@ class system_search_service
array $permissionsCatalogAll, array $permissionsCatalogAll,
array $permissionsCatalogOwn, array $permissionsCatalogOwn,
array $moduleConfigVisibility, array $moduleConfigVisibility,
array $allowedDepartmentIds,
array $forcedCustomerNumbers array $forcedCustomerNumbers
): array { ): array {
if ($this->isGenericEntityType($entityType)) { if ($this->isGenericEntityType($entityType)) {
@@ -393,6 +408,7 @@ class system_search_service
$entityBoost, $entityBoost,
$ownOnly, $ownOnly,
$ownCustomerNumber, $ownCustomerNumber,
$allowedDepartmentIds,
$forcedCustomerNumbers $forcedCustomerNumbers
); );
} }
@@ -439,9 +455,24 @@ class system_search_service
return empty(array_intersect($normalizedDirty, system_search_registry::sourceTablesForEntityType($entityType))); return empty(array_intersect($normalizedDirty, system_search_registry::sourceTablesForEntityType($entityType)));
} }
private function indexedEntitySupportsDepartmentFilter(string $entityType): bool
{
$entityType = trim(mb_strtolower($entityType));
if (in_array($entityType, ['orders', 'objects'], true)) {
return true;
}
$config = system_search_registry::genericEntityConfigs()[$entityType] ?? null;
return is_array($config)
&& isset($config['department_field'])
&& is_string($config['department_field'])
&& trim($config['department_field']) !== '';
}
/** /**
* @param array<int, string> $terms * @param array<int, string> $terms
* @param array<string, bool> $moduleConfigVisibility * @param array<string, bool> $moduleConfigVisibility
* @param array<int, int> $allowedDepartmentIds
* @param array<int, int> $forcedCustomerNumbers * @param array<int, int> $forcedCustomerNumbers
* @return array<int, array<string, mixed>> * @return array<int, array<string, mixed>>
*/ */
@@ -452,6 +483,7 @@ class system_search_service
bool $ownOnly, bool $ownOnly,
?int $ownCustomerNumber, ?int $ownCustomerNumber,
array $moduleConfigVisibility, array $moduleConfigVisibility,
array $allowedDepartmentIds,
array $forcedCustomerNumbers array $forcedCustomerNumbers
): array { ): array {
global $db; global $db;
@@ -473,6 +505,9 @@ class system_search_service
if (!empty($customerNumbers)) { if (!empty($customerNumbers)) {
$wheres[] = "`customer_number` IN (" . implode(',', array_map('intval', $customerNumbers)) . ")"; $wheres[] = "`customer_number` IN (" . implode(',', array_map('intval', $customerNumbers)) . ")";
} }
if (!empty($allowedDepartmentIds) && $this->indexedEntitySupportsDepartmentFilter($entityType)) {
$wheres[] = "`department_id` IN (" . implode(',', array_map('intval', $allowedDepartmentIds)) . ")";
}
$booleanQuery = $this->buildBooleanFullTextQuery($terms); $booleanQuery = $this->buildBooleanFullTextQuery($terms);
$rows = []; $rows = [];
@@ -1488,6 +1523,7 @@ class system_search_service
/** /**
* @param array<int, string> $terms * @param array<int, string> $terms
* @param array<int, int> $allowedDepartmentIds
* @param array<int, int> $forcedCustomerNumbers * @param array<int, int> $forcedCustomerNumbers
* @return array<int, array<string, mixed>> * @return array<int, array<string, mixed>>
*/ */
@@ -1497,6 +1533,7 @@ class system_search_service
int $entityBoost, int $entityBoost,
bool $ownOnly, bool $ownOnly,
?int $ownCustomerNumber, ?int $ownCustomerNumber,
array $allowedDepartmentIds = [],
array $forcedCustomerNumbers = [] array $forcedCustomerNumbers = []
): array { ): array {
if (empty($terms)) { if (empty($terms)) {
@@ -1602,7 +1639,9 @@ class system_search_service
$customerNumbers, $customerNumbers,
$customerField, $customerField,
$customerFieldMode, $customerFieldMode,
$fixedConditions $fixedConditions,
$allowedDepartmentIds,
$departmentField
); );
$this->primeCustomerContexts(array_values(array_unique(array_filter( $this->primeCustomerContexts(array_values(array_unique(array_filter(
@@ -1798,6 +1837,8 @@ class system_search_service
* @param string|null $customerField * @param string|null $customerField
* @param string $customerFieldMode * @param string $customerFieldMode
* @param array<string, mixed> $fixedConditions * @param array<string, mixed> $fixedConditions
* @param array<int, int> $departmentIds
* @param string|null $departmentField
* @return array<int, array<string, mixed>> * @return array<int, array<string, mixed>>
*/ */
private function searchTable( private function searchTable(
@@ -1808,7 +1849,9 @@ class system_search_service
array $customerNumbers = [], array $customerNumbers = [],
?string $customerField = null, ?string $customerField = null,
string $customerFieldMode = 'default', string $customerFieldMode = 'default',
array $fixedConditions = [] array $fixedConditions = [],
array $departmentIds = [],
?string $departmentField = null
): array { ): array {
global $db; global $db;
@@ -1844,6 +1887,10 @@ class system_search_service
} }
} }
if (!empty($departmentIds) && $departmentField !== null && in_array($departmentField, $fields, true)) {
$wheres[] = "`$departmentField` IN (" . implode(',', array_map('intval', $departmentIds)) . ")";
}
$termClauses = []; $termClauses = [];
foreach ($terms as $term) { foreach ($terms as $term) {
$escaped = $db->escape_string($term); $escaped = $db->escape_string($term);
+6 -11
View File
@@ -6,6 +6,7 @@ require_once WD . '/modules/workfeed/workfeed_c.php';
use Exception; use Exception;
use interfaces\workfeed_i; use interfaces\workfeed_i;
use workfeed\config\workfeed_api_url_c;
use workfeed\workfeed_c; use workfeed\workfeed_c;
class workfeed implements workfeed_i class workfeed implements workfeed_i
@@ -84,7 +85,7 @@ class workfeed implements workfeed_i
$companyId = $this->requireConfiguredCompanyId(); $companyId = $this->requireConfiguredCompanyId();
$url = $this->buildUrl( $url = $this->buildUrl(
$this->config->api_url->getVariableValue(), workfeed_api_url_c::normalizeApiUrlForValidation((string)$this->config->api_url->getVariableValue()),
'/companies/' . rawurlencode($companyId) . '/' . ltrim($path, '/'), '/companies/' . rawurlencode($companyId) . '/' . ltrim($path, '/'),
$query $query
); );
@@ -182,7 +183,10 @@ class workfeed implements workfeed_i
private function requireConfiguredApiUrl(): void private function requireConfiguredApiUrl(): void
{ {
$url = trim((string)$this->config->api_url->getVariableValue()); $url = trim((string)$this->config->api_url->getVariableValue());
if ($url === '' || filter_var($this->normalizeUrlForValidation($url), FILTER_VALIDATE_URL) === false) { if ($url === ''
|| filter_var(workfeed_api_url_c::normalizeApiUrlForValidation($url), FILTER_VALIDATE_URL) === false
|| !workfeed_api_url_c::isTrustedApiUrl($url)
) {
throw new Exception('Invalid Workfeed API URL configured.'); throw new Exception('Invalid Workfeed API URL configured.');
} }
} }
@@ -210,15 +214,6 @@ class workfeed implements workfeed_i
return $companyId; return $companyId;
} }
private function normalizeUrlForValidation(string $url): string
{
if (preg_match('#^https?://#i', $url)) {
return $url;
}
return 'https://' . ltrim($url, '/');
}
/** /**
* @throws Exception * @throws Exception
*/ */
+4
View File
@@ -1,6 +1,10 @@
{ {
"scripts": { "scripts": {
"test": "composer test:unit", "test": "composer test:unit",
"analyse": "vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress",
"static": "@analyse",
"rector:dry-run": "@php -d error_reporting=0 -d display_errors=0 -d log_errors=0 vendor/bin/rector process --dry-run --config rector.php",
"rector:fix": "@php -d error_reporting=0 -d display_errors=0 -d log_errors=0 vendor/bin/rector process --config rector.php",
"test:unit": "vendor/bin/pest --testsuite=Unit --colors=always", "test:unit": "vendor/bin/pest --testsuite=Unit --colors=always",
"test:integration": "vendor/bin/pest --testsuite=Integration --colors=always", "test:integration": "vendor/bin/pest --testsuite=Integration --colors=always",
"test:api": [ "test:api": [
+22 -9
View File
@@ -19,6 +19,8 @@ use classes\slack as Slack;
use classes\email as Email; use classes\email as Email;
use classes\gatewayapi as GatewayAPI; use classes\gatewayapi as GatewayAPI;
use dynamicimages\images\machine_1; use dynamicimages\images\machine_1;
use modules\selfserve\config\selfserve_dynamic_image_size_c;
use modules\selfserve\selfserve_c;
use goals\classes\goals_criteria; use goals\classes\goals_criteria;
use goals\services\goals_progress_alert_renderer; use goals\services\goals_progress_alert_renderer;
use goals\helpers\goals_criteria_progress_alert_destination as Dest; use goals\helpers\goals_criteria_progress_alert_destination as Dest;
@@ -33,6 +35,8 @@ use objects\users_o;
use routes\moduleWeatherAPIRoute; use routes\moduleWeatherAPIRoute;
require_once __DIR__ . '/../classes/economic_transfer_executor.php'; require_once __DIR__ . '/../classes/economic_transfer_executor.php';
const DYNAMIC_IMAGE_RELEVANT_MAX_WIDTH = 1600;
require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php'; require_once __DIR__ . '/../classes/economic_transfer_queue_schema_bootstrap.php';
require_once __DIR__ . '/../classes/economic_transfer_queue.php'; require_once __DIR__ . '/../classes/economic_transfer_queue.php';
require_once __DIR__ . '/../classes/workfeed_employee_name_formatter.php'; require_once __DIR__ . '/../classes/workfeed_employee_name_formatter.php';
@@ -930,6 +934,7 @@ function buildDynamicImageCacheKey(array $variant): string
'current_step' => (int)($variant['current_step'] ?? 0), 'current_step' => (int)($variant['current_step'] ?? 0),
'only_current_step' => (bool)($variant['only_current_step'] ?? false), 'only_current_step' => (bool)($variant['only_current_step'] ?? false),
'vehicle_type' => $variant['vehicle_type'] ?? null, 'vehicle_type' => $variant['vehicle_type'] ?? null,
'dynamic_image_size' => getSelfServeDynamicImageSizeModeForCron(),
]; ];
$json = json_encode($cacheParams); $json = json_encode($cacheParams);
@@ -962,16 +967,11 @@ function renderDynamicImageVariant(int $dynamicImageId, ?array $buttons, int $cu
$image->current_step = max(0, $currentStep); $image->current_step = max(0, $currentStep);
$image->only_generate_current_step = $onlyCurrentStep; $image->only_generate_current_step = $onlyCurrentStep;
$image->setup(); $image->setup();
if (getSelfServeDynamicImageSizeModeForCron() === selfserve_dynamic_image_size_c::SIZE_RELEVANT) {
$image->resizeToMaxWidth(DYNAMIC_IMAGE_RELEVANT_MAX_WIDTH);
}
$dataUri = $image->exportAsBase64('png'); return $image->exportBinary('png');
if (!preg_match('/^data:image\/png;base64,(.*)$/', $dataUri, $matches)) {
return null;
}
$imageData = base64_decode($matches[1], true);
if ($imageData === false) {
return null;
}
return $imageData;
} catch (Throwable $e) { } catch (Throwable $e) {
warn('PreRenderDynamicImagesCron: render failed for dynamic_image_id=' . $dynamicImageId . ': ' . $e->getMessage()); warn('PreRenderDynamicImagesCron: render failed for dynamic_image_id=' . $dynamicImageId . ': ' . $e->getMessage());
return null; return null;
@@ -985,6 +985,19 @@ function renderDynamicImageVariant(int $dynamicImageId, ?array $buttons, int $cu
} }
} }
function getSelfServeDynamicImageSizeModeForCron(): string
{
try {
$mode = (string)(new selfserve_c())->dynamic_image_size->getVariableValue();
} catch (Throwable) {
return selfserve_dynamic_image_size_c::SIZE_ORIGINAL;
}
return in_array($mode, [selfserve_dynamic_image_size_c::SIZE_ORIGINAL, selfserve_dynamic_image_size_c::SIZE_RELEVANT], true)
? $mode
: selfserve_dynamic_image_size_c::SIZE_ORIGINAL;
}
/** /**
* @param mixed $value * @param mixed $value
* @return array<int|string> * @return array<int|string>
+16 -15
View File
@@ -5,6 +5,21 @@ $isPreview = $_GET['preview'] ?? false;
// Remove query string if present // Remove query string if present
$file = strtok($file, '?'); $file = strtok($file, '?');
// Require authentication for direct /files/ access
if (str_contains($file, '/files/')) {
$headers = getallheaders();
$token = $_GET['token'] ?? $_POST['token'] ?? ($headers['Authorization'] ?? null);
if (!empty($token)) {
$token = str_replace('Bearer ', '', $token);
}
if (empty($token) || !(new \classes\authentication())->validate_token($token)) {
header('HTTP/1.1 401 Unauthorized');
echo 'Unauthorized';
exit;
}
}
$isPDF = false; $isPDF = false;
$isPDFStore = false; $isPDFStore = false;
$isAttachment = false; $isAttachment = false;
@@ -40,20 +55,6 @@ if ($isPDF && $isPDFStore) {
// Check if the certificate exists // Check if the certificate exists
if (!$wash_certificate_store->isFileInStore($file)) { if (!$wash_certificate_store->isFileInStore($file)) {
// Try the PDF store
$pdf_store = new \classes\pdf_store();
if ($pdf_store->isFileInStore(str_replace('/files/', '', $file))) {
// Download the certificate from the PDF store to /tmp
$certificate_path = $pdf_store->download(str_replace('/files/', '', $file));
// Send the certificate to the client
header('Content-Type: application/pdf');
header('Content-Disposition: inline; filename="' . str_replace('/files/', '', $file) . '"');
header('Content-Length: ' . filesize($certificate_path));
readfile($certificate_path);
// Delete the certificate from /tmp after sending it
unlink($certificate_path);
exit;
}
echo 'Certificate not found in store' . $file; echo 'Certificate not found in store' . $file;
//header('HTTP/1.1 404 Not Found'); //header('HTTP/1.1 404 Not Found');
exit; exit;
@@ -118,4 +119,4 @@ if (!$isPDF) {
// Delete the file from /tmp after sending it // Delete the file from /tmp after sending it
unlink($file_path); unlink($file_path);
exit; exit;
} }
+29 -9
View File
@@ -61,6 +61,17 @@ try {
spl_autoload_register(function (string $class): void { spl_autoload_register(function (string $class): void {
$class = ltrim($class, '\\'); $class = ltrim($class, '\\');
$cache_key = 'autoload:' . $class; $cache_key = 'autoload:' . $class;
$wdReal = rtrim((string) realpath(WD), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$modulesRoot = $wdReal . 'modules' . DIRECTORY_SEPARATOR;
$isPathInside = static function (string $path, string $root): bool {
$resolved = realpath($path);
if ($resolved === false) {
return false;
}
$resolved = rtrim($resolved, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
return str_starts_with($resolved, $root);
};
$is_loaded = static function (string $candidate): bool { $is_loaded = static function (string $candidate): bool {
return class_exists($candidate, false) return class_exists($candidate, false)
|| interface_exists($candidate, false) || interface_exists($candidate, false)
@@ -73,11 +84,13 @@ spl_autoload_register(function (string $class): void {
try { try {
$cached = redis->get($cache_key); $cached = redis->get($cache_key);
if (is_string($cached) && $cached !== '' && is_file($cached)) { if (is_string($cached) && $cached !== '' && is_file($cached)) {
require_once $cached; if ($isPathInside($cached, $wdReal)) {
if ($is_loaded($class)) { require_once $cached;
return; if ($is_loaded($class)) {
return;
}
} }
// Stale class mapping in cache, continue with normal lookup. // Stale, invalid, or unsafe class mapping in cache; continue with normal lookup.
redis->delete($cache_key); redis->delete($cache_key);
} elseif (is_string($cached) && $cached !== '') { } elseif (is_string($cached) && $cached !== '') {
// Remove non-existing cached path to avoid repeated failed lookups. // Remove non-existing cached path to avoid repeated failed lookups.
@@ -120,6 +133,18 @@ spl_autoload_register(function (string $class): void {
$module_dirs = redis->get_array('autoload:module_dirs'); $module_dirs = redis->get_array('autoload:module_dirs');
} catch (\Throwable $e) {} } catch (\Throwable $e) {}
} }
if (is_array($module_dirs)) {
$module_dirs = array_values(array_filter($module_dirs, static function ($item) use ($base, $modulesRoot, $isPathInside): bool {
if (!is_string($item) || $item === '' || str_contains($item, DIRECTORY_SEPARATOR) || str_contains($item, '..')) {
return false;
}
$candidate = $base . 'modules' . DIRECTORY_SEPARATOR . $item;
return is_dir($candidate) && $isPathInside($candidate, $modulesRoot);
}));
}
if ($module_dirs === null) { if ($module_dirs === null) {
$module_dirs = array_filter(scandir($base . 'modules'), function($item) use ($base) { $module_dirs = array_filter(scandir($base . 'modules'), function($item) use ($base) {
return $item !== '.' && $item !== '..' && is_dir($base . 'modules' . DIRECTORY_SEPARATOR . $item); return $item !== '.' && $item !== '..' && is_dir($base . 'modules' . DIRECTORY_SEPARATOR . $item);
@@ -237,11 +262,6 @@ if (php_sapi_name() === 'cli' || isset($_GET['internalCronCall'])) {
exit; exit;
} }
// If the route ends with .php, then require the file_server.php
if (str_contains($_SERVER['REQUEST_URI'], '.pdf')) {
require_once 'file_server.php';
exit;
}
// If the route ends with a MIME type, then require the file_server.php // If the route ends with a MIME type, then require the file_server.php
if ((preg_match('/\.(jpg|jpeg|png)$/', $_SERVER['REQUEST_URI']) || str_contains($_SERVER['REQUEST_URI'], '/files/'))) { if ((preg_match('/\.(jpg|jpeg|png)$/', $_SERVER['REQUEST_URI']) || str_contains($_SERVER['REQUEST_URI'], '/files/'))) {
require_once 'file_server.php'; require_once 'file_server.php';
@@ -12,10 +12,35 @@ interface licenseplaterecognizer_i extends universal_module_i
* @return array An array containing the plate number and other relevant information. * @return array An array containing the plate number and other relevant information.
*/ */
public function licenseplaterecognizer(string $base64_image): array; public function licenseplaterecognizer(string $base64_image): array;
/**
* Get the plate number from raw uploaded image bytes.
* @param string $image_data Raw uploaded image bytes.
* @param string $mime_type The image MIME type.
* @return array An array containing the plate number and other relevant information.
*/
public function licenseplaterecognizerUpload(string $image_data, string $mime_type = 'image/jpeg'): array;
/**
* Get the plate number from raw uploaded image bytes without building an exact-result cache key.
* @param string $image_data Raw uploaded image bytes.
* @param string $mime_type The image MIME type.
* @return array An array containing the plate number and other relevant information.
*/
public function licenseplaterecognizerUploadUncached(string $image_data, string $mime_type = 'image/jpeg'): array;
/**
* Get the plate number from a PHP upload temp file without copying it into memory.
* @param string $image_path The uploaded image temp-file path.
* @param string $mime_type The image MIME type.
* @return array An array containing the plate number and other relevant information.
*/
public function licenseplaterecognizerUploadFile(string $image_path, string $mime_type = 'image/jpeg'): array;
/** /**
* Get usage information about the license plate recognizer module. * Get usage information about the license plate recognizer module.
* @returns licenseplaterecognizer_info An object containing usage statistics and information. * @returns licenseplaterecognizer_info An object containing usage statistics and information.
* @see licenseplaterecognizer_info * @see licenseplaterecognizer_info
*/ */
public function get_usage(): licenseplaterecognizer_info; public function get_usage(): licenseplaterecognizer_info;
} }
@@ -5,6 +5,7 @@ namespace attachments\helpers;
class attachment_content class attachment_content
{ {
const OTHER_TYPE_WASH_CERTIFICATE = 'WASH_CERTIFICATE'; const OTHER_TYPE_WASH_CERTIFICATE = 'WASH_CERTIFICATE';
const OTHER_TYPE_SELF_SERVE_WASH = 'SELF_SERVE_WASH';
public ?string $image; // Used to store the attachment object name, in the attachment store. public ?string $image; // Used to store the attachment object name, in the attachment store.
public ?string $document; // Used to store the attachment object name, in the attachment store. public ?string $document; // Used to store the attachment object name, in the attachment store.
public ?attachment_relation $relation; // Used to store the attachment relation object. public ?attachment_relation $relation; // Used to store the attachment relation object.
@@ -49,4 +50,4 @@ class attachment_content
$this->relation = $relation; $this->relation = $relation;
return $this; return $this;
} }
} }
@@ -0,0 +1,25 @@
<?php
namespace modules\coolify\config;
use traits\module_config_variable;
class coolify_github_runner_backend_repository_c
{
use module_config_variable;
public function __construct()
{
$this->setupConfigVariable(
'Coolify',
'github_runner_backend_repository',
'string',
false,
null,
'GitHub backend repository that receives Coolify-managed self-hosted runners.',
'copenhagentruckwash/api',
false,
'copenhagentruckwash/api'
);
}
}
@@ -0,0 +1,25 @@
<?php
namespace modules\coolify\config;
use traits\module_config_variable;
class coolify_github_runner_count_per_repo_c
{
use module_config_variable;
public function __construct()
{
$this->setupConfigVariable(
'Coolify',
'github_runner_count_per_repo',
'int',
false,
null,
'Number of self-hosted GitHub runner containers to deploy per repository.',
'1',
false,
'1'
);
}
}
@@ -0,0 +1,25 @@
<?php
namespace modules\coolify\config;
use traits\module_config_variable;
class coolify_github_runner_frontend_repository_c
{
use module_config_variable;
public function __construct()
{
$this->setupConfigVariable(
'Coolify',
'github_runner_frontend_repository',
'string',
false,
null,
'GitHub frontend repository that receives Coolify-managed self-hosted runners.',
'copenhagentruckwash/pleno-vue',
false,
'copenhagentruckwash/pleno-vue'
);
}
}
@@ -0,0 +1,25 @@
<?php
namespace modules\coolify\config;
use traits\module_config_variable;
class coolify_github_runner_labels_c
{
use module_config_variable;
public function __construct()
{
$this->setupConfigVariable(
'Coolify',
'github_runner_labels',
'string',
false,
null,
'Comma-separated GitHub Actions runner labels registered on each Coolify-managed runner.',
'self-hosted,Linux,X64,default',
false,
'self-hosted,Linux,X64,default'
);
}
}
@@ -0,0 +1,25 @@
<?php
namespace modules\coolify\config;
use traits\module_config_variable;
class coolify_github_runner_service_uuid_c
{
use module_config_variable;
public function __construct()
{
$this->setupConfigVariable(
'Coolify',
'github_runner_service_uuid',
'string',
false,
null,
'Coolify service UUID for the managed GitHub self-hosted runner stack.',
'abc123...',
false,
''
);
}
}
@@ -0,0 +1,38 @@
<?php
namespace modules\coolify\config;
use classes\replication_secret_box;
use traits\module_config_variable;
class coolify_github_runner_token_c
{
use module_config_variable {
setVariableValue as private traitSetVariableValue;
}
public function __construct()
{
$this->setupConfigVariable(
'Coolify',
'github_runner_token',
'string',
false,
null,
'GitHub PAT used to register Coolify-managed self-hosted repository runners.',
'github_pat_...',
true,
''
);
}
public function setVariableValue(mixed $value): void
{
$value = trim((string)($value ?? ''));
if ($value !== '' && !str_starts_with($value, 'twsec:v1:')) {
$value = replication_secret_box::encrypt($value);
}
$this->traitSetVariableValue($value);
}
}
@@ -86,6 +86,14 @@ interface dynamicimages_image_i
*/ */
public function exportAsBase64(?string $format = null, int $quality = 90): string; public function exportAsBase64(?string $format = null, int $quality = 90): string;
/**
* Export the composed image as binary image data.
* @param string|null $format Optional target format (e.g. 'png', 'jpeg')
* @param int $quality Quality for lossy formats (0-100)
* @return string binary image data
*/
public function exportBinary(?string $format = null, int $quality = 90): string;
/** /**
* Directly serve the composed image to the client with proper headers. * Directly serve the composed image to the client with proper headers.
* Convenience wrapper for outputting binary image data. * Convenience wrapper for outputting binary image data.
@@ -94,4 +102,4 @@ interface dynamicimages_image_i
* @param int $quality Quality for lossy formats (0-100) * @param int $quality Quality for lossy formats (0-100)
*/ */
public function servePicture(?string $format = null, int $quality = 90): void; public function servePicture(?string $format = null, int $quality = 90): void;
} }
@@ -226,6 +226,20 @@ trait dynamicimages_image_t
return $this; return $this;
} }
public function resizeToMaxWidth(int $maxWidth): dynamicimages_image_i
{
$this->assertCanvasInitialized();
if ($maxWidth <= 0) {
throw new \InvalidArgumentException('Resize max width must be a positive integer.');
}
if ($this->canvasWidth === null || $this->canvasHeight === null || $this->canvasWidth <= $maxWidth) {
return $this;
}
$height = (int)round($this->canvasHeight * ($maxWidth / $this->canvasWidth));
return $this->resize($maxWidth, max(1, $height));
}
public function crop(int $width, int $height, int $x, int $y): dynamicimages_image_i public function crop(int $width, int $height, int $x, int $y): dynamicimages_image_i
{ {
$this->assertCanvasInitialized(); $this->assertCanvasInitialized();
@@ -307,20 +321,8 @@ trait dynamicimages_image_t
*/ */
public function exportAsBase64(?string $format = null, int $quality = 90): string public function exportAsBase64(?string $format = null, int $quality = 90): string
{ {
// If a canvas is initialized, export that as PNG by default
if ($this->image instanceof \Imagick) { if ($this->image instanceof \Imagick) {
$img = clone $this->image; return 'data:image/png;base64,' . base64_encode($this->exportBinary($format, $quality));
$img->setImageFormat('png');
// Quality influences compression for PNG differently; keep as hint
if ($format !== null && strtolower($format) !== 'png') {
// For now we only support PNG for composed images as requested
}
// Strip metadata to reduce size
$img->stripImage();
$blob = $img->getImageBlob();
$img->clear();
$img->destroy();
return 'data:image/png;base64,' . base64_encode($blob);
} }
// Fallback: export first asset as-is // Fallback: export first asset as-is
@@ -341,6 +343,40 @@ trait dynamicimages_image_t
return 'data:' . $mime . ';base64,' . base64_encode($data); return 'data:' . $mime . ';base64,' . base64_encode($data);
} }
public function exportBinary(?string $format = null, int $quality = 90): string
{
// If a canvas is initialized, export that as PNG by default
if ($this->image instanceof \Imagick) {
$img = clone $this->image;
$img->setImageFormat('png');
// Quality influences compression for PNG differently; keep as hint
if ($format !== null && strtolower($format) !== 'png') {
// For now we only support PNG for composed images as requested
}
// Strip metadata to reduce size
$img->stripImage();
$blob = $img->getImageBlob();
$img->clear();
$img->destroy();
return $blob;
}
// Fallback: export first asset as-is
if (empty($this->assets)) {
throw new \RuntimeException('No assets available to export.');
}
$asset = $this->assets[0];
$path = $asset->getPath();
if (!is_readable($path)) {
throw new \RuntimeException('Asset is not readable: ' . $path);
}
$data = file_get_contents($path);
if ($data === false) {
throw new \RuntimeException('Failed to read asset: ' . $path);
}
return $data;
}
public function getAsset(string $asset_name): ?dynamicimages_asset public function getAsset(string $asset_name): ?dynamicimages_asset
{ {
foreach ($this->assets as $asset) { foreach ($this->assets as $asset) {
@@ -357,22 +393,18 @@ trait dynamicimages_image_t
*/ */
public function outputImage(?string $format = null, int $quality = 90): void public function outputImage(?string $format = null, int $quality = 90): void
{ {
$dataUri = $this->exportAsBase64($format, $quality); $mimeType = 'image/png';
// Extract mime type and base64 data if (!$this->image instanceof \Imagick && !empty($this->assets)) {
if (preg_match('/^data:(image\/[a-zA-Z0-9+.-]+);base64,(.*)$/', $dataUri, $matches)) { $asset = $this->assets[0];
$mimeType = $matches[1]; $path = $asset->getPath();
$base64Data = $matches[2]; $imgInfo = is_readable($path) ? @getimagesize($path) : false;
// Decode base64 data $mimeType = is_array($imgInfo) && isset($imgInfo['mime']) ? $imgInfo['mime'] : 'application/octet-stream';
$imageData = base64_decode($base64Data);
if ($imageData !== false) {
// Send appropriate headers
header('Content-Type: ' . $mimeType);
header('Content-Length: ' . strlen($imageData));
// Output the image data
echo $imageData;
exit;
}
} }
$imageData = $this->exportBinary($format, $quality);
header('Content-Type: ' . $mimeType);
header('Content-Length: ' . strlen($imageData));
echo $imageData;
exit;
} }
/** /**
@@ -382,4 +414,4 @@ trait dynamicimages_image_t
{ {
$this->outputImage($format, $quality); $this->outputImage($format, $quality);
} }
} }
@@ -61,19 +61,47 @@ class economic_invoices_draft_endpoint
* @throws Exception If the request fails * @throws Exception If the request fails
*/ */
public function add_order(int $invoiceDraftId, orders_o $order, string $currency = 'DKK'): void public function add_order(int $invoiceDraftId, orders_o $order, string $currency = 'DKK'): void
{
$this->add_orders($invoiceDraftId, [$order], $currency);
}
/**
* Add many orders to a draft invoice and flush their lines in batches.
*
* @param orders_o[] $orders
* @return array{order_count:int,orders_with_invoice_lines:int,line_count:int,batch_count:int,batch_sizes:array<int,int>}
* @throws Exception If the request fails
*/
public function add_orders(int $invoiceDraftId, array $orders, string $currency = 'DKK', int $line_batch_size = 500): array
{ {
$draftInvoice = (new economic())->getInvoiceDraft($invoiceDraftId, strtoupper($currency), true); $draftInvoice = (new economic())->getInvoiceDraft($invoiceDraftId, strtoupper($currency), true);
// Check if the order includes any items that should be included in the invoice $orders_with_invoice_lines = 0;
if ($order->getIncludeInInvoiceCount() > 0) {
foreach ( $orders as $order ) {
if (!$order instanceof orders_o) {
throw new Exception('Order payload must contain orders_o instances');
}
// Check if the order includes any items that should be included in the invoice
if ($order->getIncludeInInvoiceCount() <= 0) {
continue;
}
$orders_with_invoice_lines++;
// Add the transaction header (Timestamp, department, etc.) // Add the transaction header (Timestamp, department, etc.)
$draftInvoice->addNewTransactionHeader($order); $draftInvoice->addNewTransactionHeader($order);
// Add the order lines // Add the order lines
$draftInvoice->addOrderItemLines($order); $draftInvoice->addOrderItemLines($order);
// Add an empty line, so the invoice is not empty // Add an empty line, so the invoice is not empty
$draftInvoice->addTextLine(''); $draftInvoice->addTextLine('');
// Save the draft invoice lines
$draftInvoice->addLines();
} }
$metrics = $draftInvoice->flushLinesInBatches($line_batch_size);
return [
'order_count' => count($orders),
'orders_with_invoice_lines' => $orders_with_invoice_lines,
...$metrics,
];
} }
/** /**
@@ -151,4 +179,4 @@ class economic_invoices_draft_endpoint
$draft_invoice->addLines(); $draft_invoice->addLines();
} }
} }
} }
@@ -10,6 +10,8 @@ use objects\orders_o;
class economic_invoice_draft class economic_invoice_draft
{ {
public const DEFAULT_LINE_BATCH_SIZE = 500;
/** /**
* The Economic draftInvoiceNumber * The Economic draftInvoiceNumber
* @var int $draft_invoice_number * @var int $draft_invoice_number
@@ -110,13 +112,55 @@ class economic_invoice_draft
} }
/** /**
* Add the lines to the draft invoice * Add the lines to the draft invoice.
* @return void
*/ */
public function addLines(): void public function addLines(): void
{
$this->flushLinesInBatches();
}
/**
* Add queued draft lines using chunked requests.
*
* @return array{line_count:int,batch_count:int,batch_sizes:array<int,int>}
*/
public function flushLinesInBatches(int $batch_size = self::DEFAULT_LINE_BATCH_SIZE): array
{
$lines = array_values($this->draft_lines);
$line_count = count($lines);
if ($line_count === 0) {
return [
'line_count' => 0,
'batch_count' => 0,
'batch_sizes' => [],
];
}
$batch_size = max(1, $batch_size);
$batch_sizes = [];
foreach (array_chunk($lines, $batch_size) as $batch) {
$this->sendDraftLines($batch);
$batch_sizes[] = count($batch);
}
$this->draft_lines = [];
return [
'line_count' => $line_count,
'batch_count' => count($batch_sizes),
'batch_sizes' => $batch_sizes,
];
}
public function pendingLineCount(): int
{
return count($this->draft_lines);
}
protected function sendDraftLines(array $draft_lines): object
{ {
$economic = new economic(); $economic = new economic();
$economic->invoices->draft->add_lines($this->draft_invoice_number, $this->draft_lines); return $economic->invoices->draft->add_lines($this->draft_invoice_number, $draft_lines);
} }
/** /**
@@ -6,6 +6,7 @@ use Exception;
class edge_gateway_agent_artifact_locator class edge_gateway_agent_artifact_locator
{ {
private const EDGE_AGENT_BUILD_ARTIFACT_DIRECTORY = 'build/install';
private const ROUTER_ARTIFACT_DIRECTORY = 'resources/edge-gateway-agent'; private const ROUTER_ARTIFACT_DIRECTORY = 'resources/edge-gateway-agent';
private const DEFAULT_MOUNTED_ARTIFACT_DIRECTORY = '/services/edge-agent/php-agent'; private const DEFAULT_MOUNTED_ARTIFACT_DIRECTORY = '/services/edge-agent/php-agent';
private const DEFAULT_BAKED_ARTIFACT_DIRECTORY = '/opt/truckwash-edge-agent-artifacts'; private const DEFAULT_BAKED_ARTIFACT_DIRECTORY = '/opt/truckwash-edge-agent-artifacts';
@@ -30,6 +31,10 @@ class edge_gateway_agent_artifact_locator
$candidateDirectories[] = self::routerArtifactDirectory($basePath); $candidateDirectories[] = self::routerArtifactDirectory($basePath);
foreach (self::edgeAgentBuildDirectories($basePath) as $directory) {
$candidateDirectories[] = $directory;
}
$mountedArtifactDirectory = $mountedArtifactDirectory ?? self::mountedArtifactDirectory(); $mountedArtifactDirectory = $mountedArtifactDirectory ?? self::mountedArtifactDirectory();
if ($mountedArtifactDirectory !== null) { if ($mountedArtifactDirectory !== null) {
$candidateDirectories[] = self::normalizePath($mountedArtifactDirectory); $candidateDirectories[] = self::normalizePath($mountedArtifactDirectory);
@@ -40,9 +45,9 @@ class edge_gateway_agent_artifact_locator
$candidateDirectories[] = self::normalizePath($bakedArtifactDirectory); $candidateDirectories[] = self::normalizePath($bakedArtifactDirectory);
} }
$candidateDirectories[] = self::normalizePath(dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'); foreach (self::legacyPhpAgentDirectories($basePath) as $directory) {
$candidateDirectories[] = self::normalizePath(dirname($basePath, 2) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'); $candidateDirectories[] = $directory;
$candidateDirectories[] = self::normalizePath(dirname($basePath) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'); }
$paths = []; $paths = [];
foreach (array_values(array_unique($candidateDirectories)) as $directory) { foreach (array_values(array_unique($candidateDirectories)) as $directory) {
@@ -102,6 +107,42 @@ class edge_gateway_agent_artifact_locator
return self::normalizePath($basePath . DIRECTORY_SEPARATOR . self::ROUTER_ARTIFACT_DIRECTORY); return self::normalizePath($basePath . DIRECTORY_SEPARATOR . self::ROUTER_ARTIFACT_DIRECTORY);
} }
/**
* @return array<int,string>
*/
private static function edgeAgentBuildDirectories(string $basePath): array
{
$relative = str_replace('/', DIRECTORY_SEPARATOR, self::EDGE_AGENT_BUILD_ARTIFACT_DIRECTORY);
$directories = [
dirname($basePath, 4) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . $relative,
dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . $relative,
dirname($basePath, 2) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . $relative,
dirname($basePath) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . $relative,
];
if (DIRECTORY_SEPARATOR === '/') {
array_unshift($directories, '/edge-agent/' . self::EDGE_AGENT_BUILD_ARTIFACT_DIRECTORY);
$directories[] = '/services/edge-agent/' . self::EDGE_AGENT_BUILD_ARTIFACT_DIRECTORY;
}
return array_values(array_unique(array_map(
static fn(string $directory): string => self::normalizePath($directory),
$directories
)));
}
/**
* @return array<int,string>
*/
private static function legacyPhpAgentDirectories(string $basePath): array
{
return [
self::normalizePath(dirname($basePath, 3) . DIRECTORY_SEPARATOR . 'services' . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'),
self::normalizePath(dirname($basePath, 2) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'),
self::normalizePath(dirname($basePath) . DIRECTORY_SEPARATOR . 'edge-agent' . DIRECTORY_SEPARATOR . 'php-agent'),
];
}
private static function mountedArtifactDirectory(): ?string private static function mountedArtifactDirectory(): ?string
{ {
if (DIRECTORY_SEPARATOR !== '/') { if (DIRECTORY_SEPARATOR !== '/') {
@@ -7,6 +7,7 @@ use Exception;
class edge_gateway_install_service class edge_gateway_install_service
{ {
private const ARTIFACTS = [ private const ARTIFACTS = [
'manifest.json' => 'application/json; charset=utf-8',
'agent.php' => 'application/x-httpd-php; charset=utf-8', 'agent.php' => 'application/x-httpd-php; charset=utf-8',
'lan-worker.php' => 'application/x-httpd-php; charset=utf-8', 'lan-worker.php' => 'application/x-httpd-php; charset=utf-8',
'auto-updater.php' => 'application/x-httpd-php; charset=utf-8', 'auto-updater.php' => 'application/x-httpd-php; charset=utf-8',
@@ -26,7 +27,7 @@ class edge_gateway_install_service
public function buildInstallScript(string $plainToken): string public function buildInstallScript(string $plainToken): string
{ {
return $this->manager()->buildInstallScript($plainToken); return $this->normalizeLineEndings($this->manager()->buildInstallScript($plainToken));
} }
/** /**
@@ -42,13 +43,17 @@ class edge_gateway_install_service
*/ */
public function readArtifact(string $fileName): string public function readArtifact(string $fileName): string
{ {
if ($fileName === 'manifest.json') {
return $this->buildManifest();
}
$path = $this->artifactPath($fileName); $path = $this->artifactPath($fileName);
$contents = file_get_contents($path); $contents = file_get_contents($path);
if ($contents === false) { if ($contents === false) {
throw new Exception('Unable to read edge agent artifact'); throw new Exception('Unable to read edge agent artifact');
} }
return $contents; return $this->normalizeLineEndings($contents);
} }
public function contentType(string $fileName): string public function contentType(string $fileName): string
@@ -66,15 +71,55 @@ class edge_gateway_install_service
*/ */
public function artifactPath(string $fileName): string public function artifactPath(string $fileName): string
{ {
if (!array_key_exists($fileName, self::ARTIFACTS)) { if (!array_key_exists($fileName, self::ARTIFACTS) || $fileName === 'manifest.json') {
throw new Exception('Unknown edge agent artifact'); throw new Exception('Unknown edge agent artifact');
} }
return edge_gateway_agent_artifact_locator::resolve($fileName); return edge_gateway_agent_artifact_locator::resolve($fileName);
} }
/**
* @throws Exception
*/
private function buildManifest(): string
{
$artifacts = [];
foreach (self::ARTIFACTS as $fileName => $contentType) {
if ($fileName === 'manifest.json') {
continue;
}
$path = $this->artifactPath($fileName);
$sha256 = hash_file('sha256', $path);
$bytes = filesize($path);
if ($sha256 === false || $bytes === false) {
throw new Exception('Unable to inspect edge agent artifact: ' . $fileName);
}
$artifacts[] = [
'name' => $fileName,
'sha256' => $sha256,
'bytes' => $bytes,
'content_type' => $contentType,
];
}
return json_encode([
'schema_version' => 1,
'package' => 'truckwash-edge-agent',
'version' => edge_gateway_manager::DEFAULT_INSTALL_VERSION,
'generated_at' => gmdate('c'),
'artifacts' => $artifacts,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL;
}
private function manager(): edge_gateway_manager private function manager(): edge_gateway_manager
{ {
return $this->manager ?? new edge_gateway_manager(); return $this->manager ?? new edge_gateway_manager();
} }
private function normalizeLineEndings(string $contents): string
{
return str_replace(["\r\n", "\r"], "\n", $contents);
}
} }
File diff suppressed because it is too large Load Diff
@@ -18,8 +18,7 @@ class edge_gateway_schema_bootstrap
return; return;
} }
global $db; $pdo = self::pdo();
$queries = [ $queries = [
"CREATE TABLE IF NOT EXISTS edge_gateways ( "CREATE TABLE IF NOT EXISTS edge_gateways (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
@@ -124,6 +123,31 @@ class edge_gateway_schema_bootstrap
INDEX idx_edge_gateway_command_type (command_type) INDEX idx_edge_gateway_command_type (command_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS edge_gateway_expected_relay_states (
id INT AUTO_INCREMENT PRIMARY KEY,
gateway_id INT NOT NULL,
department_id INT NOT NULL,
lane_id INT NOT NULL,
role VARCHAR(32) NOT NULL,
relay_id VARCHAR(255) NOT NULL,
device_id VARCHAR(255) NULL,
local_ip VARCHAR(64) NULL,
device_type VARCHAR(64) NOT NULL DEFAULT 'UNKNOWN',
expected_state TINYINT(1) NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL,
completed_at DATETIME NULL,
last_attempted_at DATETIME NULL,
last_error TEXT NULL,
metadata_json JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
row_updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL DEFAULT NULL,
UNIQUE KEY uniq_edge_gateway_expected_relay_state (gateway_id, lane_id, role),
INDEX idx_edge_gateway_expected_relay_gateway (gateway_id),
INDEX idx_edge_gateway_expected_relay_department (department_id),
INDEX idx_edge_gateway_expected_relay_pending (gateway_id, completed_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS edge_gateway_operations ( "CREATE TABLE IF NOT EXISTS edge_gateway_operations (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
gateway_id INT NOT NULL, gateway_id INT NOT NULL,
@@ -233,11 +257,18 @@ class edge_gateway_schema_bootstrap
]; ];
foreach ($queries as $sql) { foreach ($queries as $sql) {
$db->query($sql); $pdo->exec($sql);
} }
self::ensureColumn('edge_gateway_command_jobs', 'delivery_json', 'JSON NULL AFTER response_json'); self::ensureColumn('edge_gateway_command_jobs', 'delivery_json', 'JSON NULL AFTER response_json');
self::ensureColumn('edge_gateway_expected_relay_states', 'device_type', "VARCHAR(64) NOT NULL DEFAULT 'UNKNOWN' AFTER local_ip");
self::ensureColumn('edge_gateway_expected_relay_states', 'last_attempted_at', 'DATETIME NULL AFTER completed_at');
self::ensureColumn('edge_gateway_expected_relay_states', 'last_error', 'TEXT NULL AFTER last_attempted_at');
self::ensureColumn('edge_gateway_expected_relay_states', 'metadata_json', 'JSON NULL AFTER last_error');
self::ensureColumn('edge_gateway_expected_relay_states', 'row_updated_at', 'TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP AFTER created_at');
self::ensureColumn('edge_gateway_expected_relay_states', 'deleted_at', 'TIMESTAMP NULL DEFAULT NULL AFTER row_updated_at');
self::ensureColumn('edge_gateway_relay_bindings', 'fallback_mode', "VARCHAR(32) NOT NULL DEFAULT 'PREFER_LOCAL' AFTER channel"); self::ensureColumn('edge_gateway_relay_bindings', 'fallback_mode', "VARCHAR(32) NOT NULL DEFAULT 'PREFER_LOCAL' AFTER channel");
self::ensureColumn('edge_gateway_operations', 'type', "VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY' AFTER gateway_id"); self::ensureColumn('edge_gateway_operations', 'type', "VARCHAR(32) NOT NULL DEFAULT 'DISCOVERY' AFTER gateway_id");
@@ -304,8 +335,6 @@ class edge_gateway_schema_bootstrap
private static function ensureColumn(string $table, string $column, string $definition): void private static function ensureColumn(string $table, string $column, string $definition): void
{ {
global $db;
if (self::tableHasColumn($table, $column)) { if (self::tableHasColumn($table, $column)) {
return; return;
} }
@@ -314,7 +343,7 @@ class edge_gateway_schema_bootstrap
throw new \RuntimeException('Invalid schema bootstrap identifier'); throw new \RuntimeException('Invalid schema bootstrap identifier');
} }
$db->query( self::pdo()->exec(
"ALTER TABLE `$table` "ALTER TABLE `$table`
ADD COLUMN `$column` $definition" ADD COLUMN `$column` $definition"
); );
@@ -327,8 +356,6 @@ class edge_gateway_schema_bootstrap
string $definition, string $definition,
?string $afterColumn = null ?string $afterColumn = null
): void { ): void {
global $db;
if (!self::tableHasColumn($table, $from) || self::tableHasColumn($table, $to)) { if (!self::tableHasColumn($table, $from) || self::tableHasColumn($table, $to)) {
return; return;
} }
@@ -342,7 +369,7 @@ class edge_gateway_schema_bootstrap
$positionClause = $afterColumn === null ? '' : " AFTER `$afterColumn`"; $positionClause = $afterColumn === null ? '' : " AFTER `$afterColumn`";
$db->query( self::pdo()->exec(
"ALTER TABLE `$table` "ALTER TABLE `$table`
CHANGE COLUMN `$from` `$to` $definition$positionClause" CHANGE COLUMN `$from` `$to` $definition$positionClause"
); );
@@ -350,38 +377,35 @@ class edge_gateway_schema_bootstrap
private static function tableHasColumn(string $table, string $column): bool private static function tableHasColumn(string $table, string $column): bool
{ {
global $db; if (!preg_match('/^[A-Za-z0-9_]+$/', $table) || !preg_match('/^[A-Za-z0-9_]+$/', $column)) {
throw new \RuntimeException('Invalid schema bootstrap identifier');
$table = $db->escape_string($table);
$column = $db->escape_string($column);
$database = $db->escape_string($db->getDatabase());
$result = $db->query(
"SELECT COUNT(*) AS c
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = '$database'
AND TABLE_NAME = '$table'
AND COLUMN_NAME = '$column'"
);
if (!$result) {
return false;
} }
$row = $result->fetch_assoc(); $statement = self::pdo()->prepare(
"SELECT COUNT(*) AS c
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = :table
AND COLUMN_NAME = :column"
);
$statement->execute([
':table' => $table,
':column' => $column,
]);
$row = $statement->fetch();
return ((int)($row['c'] ?? 0)) > 0; return ((int)($row['c'] ?? 0)) > 0;
} }
private static function syncOperationTypeColumns(): void private static function syncOperationTypeColumns(): void
{ {
global $db;
if (!self::tableHasColumn('edge_gateway_operations', 'type') if (!self::tableHasColumn('edge_gateway_operations', 'type')
|| !self::tableHasColumn('edge_gateway_operations', 'operation_type')) { || !self::tableHasColumn('edge_gateway_operations', 'operation_type')) {
return; return;
} }
$db->query( $pdo = self::pdo();
$pdo->exec(
"UPDATE edge_gateway_operations "UPDATE edge_gateway_operations
SET type = operation_type SET type = operation_type
WHERE operation_type IS NOT NULL WHERE operation_type IS NOT NULL
@@ -389,7 +413,7 @@ class edge_gateway_schema_bootstrap
AND (type IS NULL OR type = '' OR type <> operation_type)" AND (type IS NULL OR type = '' OR type <> operation_type)"
); );
$db->query( $pdo->exec(
"UPDATE edge_gateway_operations "UPDATE edge_gateway_operations
SET operation_type = type SET operation_type = type
WHERE type IS NOT NULL WHERE type IS NOT NULL
@@ -397,4 +421,88 @@ class edge_gateway_schema_bootstrap
AND (operation_type IS NULL OR operation_type = '')" AND (operation_type IS NULL OR operation_type = '')"
); );
} }
private static function pdo(): \PDO
{
$envConfig = self::databaseConfigFromEnvironment();
if ($envConfig !== null) {
return self::connectPdo($envConfig);
}
if (!class_exists(db::class, false)) {
$dbClassPath = __DIR__ . '/../../../classes/db.php';
if (is_file($dbClassPath)) {
require_once $dbClassPath;
}
}
if (!class_exists(db::class)) {
throw new \RuntimeException('Database connection helper is not available.');
}
return db::getPDO();
}
/**
* @return array{host:string,user:string,password:string,database:string,port:int}|null
*/
private static function databaseConfigFromEnvironment(): ?array
{
$target = strtolower(self::envString('CONFIG_DB_TARGET') ?: 'live');
if ($target !== 'debug') {
$target = 'live';
}
$host = self::databaseEnvValue('HOST', $target);
$user = self::databaseEnvValue('USER', $target);
$database = self::databaseEnvValue('DATABASE', $target);
if ($host === '' || $user === '' || $database === '') {
return null;
}
return [
'host' => $host,
'user' => $user,
'password' => self::databaseEnvValue('PASSWORD', $target),
'database' => $database,
'port' => (int)(self::databaseEnvValue('PORT', $target) ?: 3306),
];
}
private static function databaseEnvValue(string $key, string $target): string
{
$liveValue = self::envString('CONFIG_DB_' . $key);
$debugValue = self::envString('CONFIG_DB_DEBUG_' . $key);
if ($target === 'debug' && $debugValue !== '') {
return $debugValue;
}
return $liveValue;
}
private static function envString(string $key): string
{
$value = getenv($key);
if ($value === false || $value === null) {
$value = $_ENV[$key] ?? $_SERVER[$key] ?? '';
}
return trim((string)$value);
}
/**
* @param array{host:string,user:string,password:string,database:string,port:int} $config
*/
private static function connectPdo(array $config): \PDO
{
$port = $config['port'] > 0 ? $config['port'] : 3306;
$dsn = "mysql:host={$config['host']};port={$port};dbname={$config['database']};charset=utf8mb4";
return new \PDO($dsn, $config['user'], $config['password'], [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
\PDO::ATTR_EMULATE_PREPARES => false,
]);
}
} }
@@ -41,7 +41,6 @@ class edgegateway_c
edgegateway_broker_url_c::class, edgegateway_broker_url_c::class,
edgegateway_public_broker_url_c::class, edgegateway_public_broker_url_c::class,
edgegateway_broker_auth_mode_c::class, edgegateway_broker_auth_mode_c::class,
edgegateway_broker_shared_secret_c::class,
]); ]);
$this->enabled = new edgegateway_enabled_c(); $this->enabled = new edgegateway_enabled_c();
$this->default_release_channel = new edgegateway_default_release_channel_c(); $this->default_release_channel = new edgegateway_default_release_channel_c();
@@ -39,7 +39,15 @@ class edgeGatewayConfigRoute
} }
(new logs_o())->add('edgegateway_config', 'global', 1, $user->id, 'EDGEGATEWAY_CONFIG', 'Successfully fetched edge gateway config'); (new logs_o())->add('edgegateway_config', 'global', 1, $user->id, 'EDGEGATEWAY_CONFIG', 'Successfully fetched edge gateway config');
$response->success((new edgegateway())->config->getConfigRequest()); $config = (new edgegateway())->config->getConfigRequest();
foreach ($config as &$entry) {
if (($entry['variable'] ?? null) === 'broker_shared_secret') {
$entry['value'] = '';
}
}
unset($entry);
$response->success($config);
} }
private function handlePostConfig(): void private function handlePostConfig(): void
@@ -71,7 +79,12 @@ class edgeGatewayConfigRoute
} }
$payload = self::getParametersAsArray(); $payload = self::getParametersAsArray();
$diagnosticOptions = array_intersect_key($payload, array_flip([
'target',
'broker_auth_mode',
'broker_shared_secret',
]));
(new logs_o())->add('edgegateway_config', 'global', 1, $user->id, 'EDGEGATEWAY_BROKER_DIAGNOSTICS', 'Tested edge gateway broker config'); (new logs_o())->add('edgegateway_config', 'global', 1, $user->id, 'EDGEGATEWAY_BROKER_DIAGNOSTICS', 'Tested edge gateway broker config');
$response->success((new edge_gateway_manager())->diagnoseBrokerConfiguration($payload)); $response->success((new edge_gateway_manager())->diagnoseBrokerConfiguration($diagnosticOptions));
} }
} }
@@ -81,6 +81,7 @@ class edgeGatewaysRoute
$this->get('/edge-agent/install-token/verify', fn() => $this->handleInstallTokenVerify()); $this->get('/edge-agent/install-token/verify', fn() => $this->handleInstallTokenVerify());
$this->post('/edge-agent/install-token/status', fn() => $this->handleAgentInstallTokenStatus()); $this->post('/edge-agent/install-token/status', fn() => $this->handleAgentInstallTokenStatus());
$this->get('/edge-agent/install.sh', fn() => $this->renderInstallScript()); $this->get('/edge-agent/install.sh', fn() => $this->renderInstallScript());
$this->get('/edge-agent/artifacts/manifest.json', fn() => $this->renderArtifact('manifest.json'));
$this->get('/edge-agent/artifacts/agent.php', fn() => $this->renderArtifact('agent.php')); $this->get('/edge-agent/artifacts/agent.php', fn() => $this->renderArtifact('agent.php'));
$this->get('/edge-agent/artifacts/lan-worker.php', fn() => $this->renderArtifact('lan-worker.php')); $this->get('/edge-agent/artifacts/lan-worker.php', fn() => $this->renderArtifact('lan-worker.php'));
$this->get('/edge-agent/artifacts/auto-updater.php', fn() => $this->renderArtifact('auto-updater.php')); $this->get('/edge-agent/artifacts/auto-updater.php', fn() => $this->renderArtifact('auto-updater.php'));
@@ -98,6 +99,8 @@ class edgeGatewaysRoute
$this->post('/edge-agent/gateways/{id}/operations/{operationId}/complete', fn() => $this->handleAgentOperationComplete()); $this->post('/edge-agent/gateways/{id}/operations/{operationId}/complete', fn() => $this->handleAgentOperationComplete());
$this->post('/edge-agent/gateways/{id}/commands/poll', fn() => $this->handleAgentCommandPoll()); $this->post('/edge-agent/gateways/{id}/commands/poll', fn() => $this->handleAgentCommandPoll());
$this->post('/edge-agent/gateways/{id}/commands/{jobId}/result', fn() => $this->handleAgentCommandResult()); $this->post('/edge-agent/gateways/{id}/commands/{jobId}/result', fn() => $this->handleAgentCommandResult());
$this->post('/edge-agent/gateways/{id}/expected-relay-states', fn() => $this->handleAgentExpectedRelayStates());
$this->post('/edge-agent/gateways/{id}/relay-state-results', fn() => $this->handleAgentRelayStateResults());
$this->post('/edge-agent/gateways/{id}/presence', fn() => $this->handleAgentPresence()); $this->post('/edge-agent/gateways/{id}/presence', fn() => $this->handleAgentPresence());
$this->post('/edge-agent/gateways/{id}/selfserve/machine-signal-bindings', fn() => $this->handleAgentSelfserveMachineSignalBindings()); $this->post('/edge-agent/gateways/{id}/selfserve/machine-signal-bindings', fn() => $this->handleAgentSelfserveMachineSignalBindings());
$this->post('/edge-agent/gateways/{id}/selfserve/machine-signal', fn() => $this->handleAgentSelfserveMachineSignal()); $this->post('/edge-agent/gateways/{id}/selfserve/machine-signal', fn() => $this->handleAgentSelfserveMachineSignal());
@@ -109,6 +112,7 @@ class edgeGatewaysRoute
$this->post('/edge-agent/internal/gateways/{id}/operations/{operationId}/events', fn() => $this->handleBrokerOperationEvent()); $this->post('/edge-agent/internal/gateways/{id}/operations/{operationId}/events', fn() => $this->handleBrokerOperationEvent());
$this->post('/edge-agent/internal/gateways/{id}/operations/{operationId}/complete', fn() => $this->handleBrokerOperationComplete()); $this->post('/edge-agent/internal/gateways/{id}/operations/{operationId}/complete', fn() => $this->handleBrokerOperationComplete());
$this->post('/edge-agent/internal/gateways/{id}/logs', fn() => $this->handleBrokerGatewayLogEntry()); $this->post('/edge-agent/internal/gateways/{id}/logs', fn() => $this->handleBrokerGatewayLogEntry());
$this->post('/edge-agent/internal/gateways/{id}/selfserve/machine-signal', fn() => $this->handleBrokerSelfserveMachineSignal());
$this->post('/edge-agent/internal/browser-streams/validate', fn() => $this->handleBrokerBrowserStreamValidate()); $this->post('/edge-agent/internal/browser-streams/validate', fn() => $this->handleBrokerBrowserStreamValidate());
$this->post('/edge-agent/internal/shell-sessions/validate', fn() => $this->handleBrokerShellSessionValidate()); $this->post('/edge-agent/internal/shell-sessions/validate', fn() => $this->handleBrokerShellSessionValidate());
$this->post('/edge-agent/internal/shell-sessions/opened', fn() => $this->handleBrokerShellSessionOpened()); $this->post('/edge-agent/internal/shell-sessions/opened', fn() => $this->handleBrokerShellSessionOpened());
@@ -398,7 +402,11 @@ class edgeGatewaysRoute
$response->error('Missing token', 400); $response->error('Missing token', 400);
} }
$response->success($this->install()->verifyInstallToken($token)); try {
$response->success($this->install()->verifyInstallToken($token));
} catch (Exception $exception) {
$response->error($exception->getMessage(), 400);
}
} }
private function handleAgentInstallTokenStatus(): void private function handleAgentInstallTokenStatus(): void
@@ -407,17 +415,21 @@ class edgeGatewaysRoute
self::requireParameters(['token', 'status']); self::requireParameters(['token', 'status']);
$payload = self::getParametersAsArray(); $payload = self::getParametersAsArray();
$response->success($this->registry()->reportInstallTokenStatus( try {
(string)$payload['token'], $response->success($this->registry()->reportInstallTokenStatus(
[ (string)$payload['token'],
'status' => (string)$payload['status'], [
'step' => isset($payload['step']) ? (string)$payload['step'] : null, 'status' => (string)$payload['status'],
'message' => isset($payload['message']) ? (string)$payload['message'] : null, 'step' => isset($payload['step']) ? (string)$payload['step'] : null,
'diagnostics' => isset($payload['diagnostics']) && is_array($payload['diagnostics']) ? (array)$payload['diagnostics'] : [], 'message' => isset($payload['message']) ? (string)$payload['message'] : null,
'gateway_id' => isset($payload['gateway_id']) ? (int)$payload['gateway_id'] : null, 'diagnostics' => isset($payload['diagnostics']) && is_array($payload['diagnostics']) ? (array)$payload['diagnostics'] : [],
'last_error' => isset($payload['last_error']) ? (string)$payload['last_error'] : null, 'gateway_id' => isset($payload['gateway_id']) ? (int)$payload['gateway_id'] : null,
] 'last_error' => isset($payload['last_error']) ? (string)$payload['last_error'] : null,
)); ]
));
} catch (Exception $exception) {
$response->error($exception->getMessage(), 400);
}
} }
private function renderArtifact(string $fileName): void private function renderArtifact(string $fileName): void
@@ -439,15 +451,19 @@ class edgeGatewaysRoute
self::requireParameters(['token']); self::requireParameters(['token']);
$payload = self::getParametersAsArray(); $payload = self::getParametersAsArray();
$response->success( try {
$this->registry()->claimGateway( $response->success(
(string)$payload['token'], $this->registry()->claimGateway(
trim((string)($payload['hostname'] ?? gethostname() ?: 'unknown-gateway')), (string)$payload['token'],
isset($payload['installed_version']) ? (string)$payload['installed_version'] : null, trim((string)($payload['hostname'] ?? gethostname() ?: 'unknown-gateway')),
isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : [] isset($payload['installed_version']) ? (string)$payload['installed_version'] : null,
), isset($payload['metadata']) && is_array($payload['metadata']) ? (array)$payload['metadata'] : []
201 ),
); 201
);
} catch (Exception $exception) {
$response->error($exception->getMessage(), 400);
}
} }
private function handleAgentHeartbeat(): void private function handleAgentHeartbeat(): void
@@ -555,6 +571,31 @@ class edgeGatewaysRoute
)); ));
} }
private function handleAgentExpectedRelayStates(): void
{
global /** @var response $response */ $response;
$gatewayId = (int)$this->fromRoute('id');
$payload = self::getParametersAsArray();
$response->success($this->manager()->buildExpectedRelayStatesForAgent(
$gatewayId,
$this->requireAgentToken($payload),
isset($payload['wait_seconds']) ? (int)$payload['wait_seconds'] : 0
));
}
private function handleAgentRelayStateResults(): void
{
global /** @var response $response */ $response;
$gatewayId = (int)$this->fromRoute('id');
$payload = self::getParametersAsArray();
$results = isset($payload['results']) && is_array($payload['results']) ? (array)$payload['results'] : [];
$response->success($this->manager()->recordExpectedRelayStateResults(
$gatewayId,
$this->requireAgentToken($payload),
$results
));
}
private function handleAgentPresence(): void private function handleAgentPresence(): void
{ {
global /** @var response $response */ $response; global /** @var response $response */ $response;
@@ -706,6 +747,21 @@ class edgeGatewaysRoute
)); ));
} }
private function handleBrokerSelfserveMachineSignal(): void
{
global /** @var response $response */ $response;
$this->requireBrokerSecret();
$gatewayId = (int)$this->fromRoute('id');
$payload = self::getParametersAsArray();
try {
$result = (new selfserve_machine_signal())->recordBrokerEdgeGatewaySignal($gatewayId, $payload);
$response->success($result, !empty($result['recorded']) ? 201 : 202);
} catch (\Throwable $exception) {
$response->error($exception->getMessage(), 400);
}
}
private function handleBrokerBrowserStreamValidate(): void private function handleBrokerBrowserStreamValidate(): void
{ {
global /** @var response $response */ $response; global /** @var response $response */ $response;
@@ -2,7 +2,8 @@
namespace email\templates; namespace email\templates;
use email\helpers\email_template;use objects\users_o; use email\helpers\email_template;
use objects\users_o;
class email_template_new_customer class email_template_new_customer
{ {
@@ -52,6 +53,7 @@ class email_template_new_customer
*/ */
public function generate_html(): string public function generate_html(): string
{ {
$customer_label = htmlspecialchars($this->getCustomerRegistrationLabel(), ENT_QUOTES, 'UTF-8');
ob_start(); ob_start();
# Start of the html # Start of the html
?> ?>
@@ -73,7 +75,7 @@ class email_template_new_customer
<!-- Intro --> <!-- Intro -->
<p class="container-text-md" style="color:#000000;font-size:16px;line-height:1.5;margin:0 0 18px 0;mso-line-height-rule:exactly;"> <p class="container-text-md" style="color:#000000;font-size:16px;line-height:1.5;margin:0 0 18px 0;mso-line-height-rule:exactly;">
Tak for din registrering af <?=((new users_o())->getCustomerName((int)$this->customer_number))?><?=(((new users_o())->getCustomerEcocomicData((int)$this->customer_number)->economic_customer->corporateIdentificationNumber) ? ' (' . (new users_o())->getCustomerEcocomicData((int)$this->customer_number)->economic_customer->corporateIdentificationNumber . ')' : '')?> som kunde hos Truck Wash. Tak for din registrering af <?=$customer_label?> som kunde hos Truck Wash.
</p> </p>
<!-- You can now wash your trucks --> <!-- You can now wash your trucks -->
@@ -185,4 +187,19 @@ class email_template_new_customer
# End of the html # End of the html
return ob_get_clean(); return ob_get_clean();
} }
private function getCustomerRegistrationLabel(): string
{
$customer = (new users_o())->getUserByCustomerNumber($this->customer_number);
$customer_name = trim((string)($customer->getCustomerName($this->customer_number) ?? ''));
$customer_label = $customer_name === '' ? 'virksomhed (CVR)' : $customer_name;
$customer->getCustomerEcocomicData($this->customer_number);
$corporate_identification_number = trim((string)($customer->economic_customer->corporateIdentificationNumber ?? ''));
if ($corporate_identification_number !== '') {
$customer_label .= ' (' . $corporate_identification_number . ')';
}
return $customer_label;
}
} }
@@ -29,8 +29,8 @@ class limble_request implements limble_request_i
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
// Set options to return the response and handle SSL // Set options to return the response and handle SSL
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
// Execute the request // Execute the request
$response = curl_exec($ch); $response = curl_exec($ch);
// Check for errors // Check for errors
@@ -44,11 +44,7 @@ class limble_request implements limble_request_i
// Check if the response is successful // Check if the response is successful
if ($httpCode < 200 || $httpCode >= 300) { if ($httpCode < 200 || $httpCode >= 300) {
$slack = new \classes\slack(); $slack = new \classes\slack();
echo 'Attempting credentials: ' . $url . ' with method: ' . $method . ' and data: ' . json_encode($data) . "\n"; $slack->send_message('Limble Request Failed with status code: ' . $httpCode, 'Limble Request Error');
echo 'Response: ' . $response . "\n";
echo 'HTTP Code: ' . $httpCode . "\n";
echo 'Headers: ' . json_encode($headers) . "\n";
$slack->send_message('Limble Request Failed: ' . $response, 'Limble Request Error');
throw new \Exception('Request failed with status code ' . $httpCode); throw new \Exception('Request failed with status code ' . $httpCode);
} }
// Check if the response is valid JSON // Check if the response is valid JSON
@@ -68,4 +64,4 @@ class limble_request implements limble_request_i
// Generate the Basic Auth header using the client ID and secret // Generate the Basic Auth header using the client ID and secret
return 'Authorization: Basic ' . base64_encode($client_id . ':' . $client_secret); return 'Authorization: Basic ' . base64_encode($client_id . ':' . $client_secret);
} }
} }
@@ -6,6 +6,8 @@ class selfserve_lane_command_arguments
{ {
public ?string $license_plate = null; public ?string $license_plate = null;
public ?int $customer_number = null; public ?int $customer_number = null;
public ?int $subuser_id = null;
public ?string $wash_mode = null;
public bool $defer_relay_side_effects = false; public bool $defer_relay_side_effects = false;
/** /**
@@ -25,6 +27,28 @@ class selfserve_lane_command_arguments
return $this; return $this;
} }
public function setSubuserId(?int $subuser_id): self
{
$this->subuser_id = $subuser_id !== null && $subuser_id > 0 ? $subuser_id : null;
return $this;
}
public function setWashMode(?string $wash_mode): self
{
$normalized = strtolower(trim((string)$wash_mode));
if ($wash_mode === null || $normalized === '') {
$this->wash_mode = null;
return $this;
}
if (!in_array($normalized, ['manual', 'machine'], true)) {
throw new \InvalidArgumentException('Invalid wash type: ' . $wash_mode);
}
$this->wash_mode = $normalized;
return $this;
}
public function setDeferRelaySideEffects(bool $defer_relay_side_effects): self public function setDeferRelaySideEffects(bool $defer_relay_side_effects): self
{ {
$this->defer_relay_side_effects = $defer_relay_side_effects; $this->defer_relay_side_effects = $defer_relay_side_effects;
@@ -40,6 +64,15 @@ class selfserve_lane_command_arguments
if (array_key_exists('customer_number', $params)) { if (array_key_exists('customer_number', $params)) {
$this->setCustomerNumber($params['customer_number']); $this->setCustomerNumber($params['customer_number']);
} }
if (array_key_exists('subuser_id', $params)) {
$this->setSubuserId($params['subuser_id'] === null ? null : (int)$params['subuser_id']);
}
if (array_key_exists('wash_type', $params)) {
$this->setWashMode($params['wash_type'] === null ? null : (string)$params['wash_type']);
}
if (array_key_exists('wash_mode', $params)) {
$this->setWashMode($params['wash_mode'] === null ? null : (string)$params['wash_mode']);
}
if (array_key_exists('defer_relay_side_effects', $params)) { if (array_key_exists('defer_relay_side_effects', $params)) {
$this->setDeferRelaySideEffects(filter_var( $this->setDeferRelaySideEffects(filter_var(
$params['defer_relay_side_effects'], $params['defer_relay_side_effects'],
@@ -148,6 +148,25 @@ class selfserve_machine_signal
$gateway = (new edge_gateway_manager())->authenticateGateway($gatewayId, $agentToken); $gateway = (new edge_gateway_manager())->authenticateGateway($gatewayId, $agentToken);
$departmentId = (int)$gateway->department_id->value(); $departmentId = (int)$gateway->department_id->value();
return $this->recordEdgeGatewaySignalForDepartment($gatewayId, $departmentId, $payload);
}
/**
* @param array<string,mixed> $payload
* @return array<string,mixed>
*/
public function recordBrokerEdgeGatewaySignal(int $gatewayId, array $payload): array
{
$gateway = (new edge_gateway_manager())->getGateway($gatewayId);
return $this->recordEdgeGatewaySignalForDepartment($gatewayId, (int)$gateway['department_id'], $payload);
}
/**
* @param array<string,mixed> $payload
* @return array<string,mixed>
*/
private function recordEdgeGatewaySignalForDepartment(int $gatewayId, int $departmentId, array $payload): array
{
return $this->recordCloudShellySignal( return $this->recordCloudShellySignal(
$departmentId, $departmentId,
isset($payload['lane_id']) ? (int)$payload['lane_id'] : null, isset($payload['lane_id']) ? (int)$payload['lane_id'] : null,
@@ -83,7 +83,7 @@ class selfserve_studio_action_runner
continue; continue;
} }
$conditionId = $action['condition_id']; $conditionId = $action['condition_id'];
if ($conditionId !== null && $conditionResults !== null && (($conditionResults[$conditionId] ?? false) !== true)) { if ($conditionId !== null && (($conditionResults[$conditionId] ?? false) !== true)) {
continue; continue;
} }
$actions[] = $action; $actions[] = $action;
File diff suppressed because it is too large Load Diff
@@ -103,49 +103,79 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
public function synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true, ?int $vehicleTypeIdOverride = null, bool $syncRelayState = true, array $options = []): array public function synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true, ?int $vehicleTypeIdOverride = null, bool $syncRelayState = true, array $options = []): array
{ {
$snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options); $snapshot = $this->buildEligibilitySnapshot($laneId, $reg, $customerNumber, $vehicleTypeIdOverride, $options);
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']); $mutationResult = $this->withSessionMutationLock(
$laneId,
$snapshot['reg'],
$snapshot['customer_number'],
function () use ($laneId, $snapshot, $options): array {
$session = $this->findLatestOpenSession($laneId, $snapshot['reg'], $snapshot['customer_number']);
$createSession = (bool)($options['create_session'] ?? true);
if (($snapshot['evaluation_trace']['disabled_lane'] ?? false) === true) { if (($snapshot['evaluation_trace']['disabled_lane'] ?? false) === true) {
return $session->exists() return [
? $this->getSessionSummary((int)$session->id) 'session' => $session,
: $this->formatBlockedSessionSummary($snapshot); 'response' => $session->exists()
? $this->getSessionSummary((int)$session->id)
: $this->formatBlockedSessionSummary($snapshot),
];
}
if (!$session->exists() && !$createSession) {
return [
'session' => $session,
'response' => $this->formatSnapshotResponse($snapshot, null),
];
}
if (!$session->exists()) {
$session = (new selfserve_wash_sessions_o())->add(
$laneId,
(int)$snapshot['lane']['department'],
$snapshot['machine_type']['id'] ?? null,
$snapshot['customer_number'],
$snapshot['reg'],
$snapshot['vehicle']['id'] ?? null,
$snapshot['vehicle']['type'] ?? null,
$this->deriveBaseStatus($snapshot),
(bool)$snapshot['allowed'],
$this->buildSessionMetadata($snapshot),
);
} else {
$session->machine_type_id->set($snapshot['machine_type']['id'] ?? null);
$session->customer_number->set($snapshot['customer_number']);
$session->vehicle_id->set($snapshot['vehicle']['id'] ?? null);
$session->vehicle_type_id->set($snapshot['vehicle_type_id']);
$session->reg->set($snapshot['reg']);
$session->allowed->set((bool)$snapshot['allowed']);
$session->metadata_json->set($this->buildSessionMetadata($snapshot));
$session->updateStatus($this->deriveCurrentStatus($snapshot, $session));
}
$this->syncSessionAnswers((int)$session->id, $snapshot['questions']);
$this->syncSessionTasks((int)$session->id, $snapshot['tasks']);
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_SYNCED, [
'allowed' => (bool)$snapshot['allowed'],
'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'],
'allowed_services' => $snapshot['allowed_services'],
'task_ids' => array_map(static fn(array $task): int => (int)$task['id'], $snapshot['tasks']),
]);
return [
'session' => $session,
'response' => null,
];
}
);
$session = $mutationResult['session'];
if ($mutationResult['response'] !== null) {
return $mutationResult['response'];
} }
if (!$session->exists()) {
$session = (new selfserve_wash_sessions_o())->add(
$laneId,
(int)$snapshot['lane']['department'],
$snapshot['machine_type']['id'] ?? null,
$snapshot['customer_number'],
$snapshot['reg'],
$snapshot['vehicle']['id'] ?? null,
$snapshot['vehicle']['type'] ?? null,
$this->deriveBaseStatus($snapshot),
(bool)$snapshot['allowed'],
$this->buildSessionMetadata($snapshot),
);
} else {
$session->machine_type_id->set($snapshot['machine_type']['id'] ?? null);
$session->customer_number->set($snapshot['customer_number']);
$session->vehicle_id->set($snapshot['vehicle']['id'] ?? null);
$session->vehicle_type_id->set($snapshot['vehicle_type_id']);
$session->reg->set($snapshot['reg']);
$session->allowed->set((bool)$snapshot['allowed']);
$session->metadata_json->set($this->buildSessionMetadata($snapshot));
$session->updateStatus($this->deriveCurrentStatus($snapshot, $session));
}
$this->syncSessionAnswers((int)$session->id, $snapshot['questions']);
$this->syncSessionTasks((int)$session->id, $snapshot['tasks']);
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_SYNCED, [
'allowed' => (bool)$snapshot['allowed'],
'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'],
'allowed_services' => $snapshot['allowed_services'],
'task_ids' => array_map(static fn(array $task): int => (int)$task['id'], $snapshot['tasks']),
]);
if ($syncRelayState) { if ($syncRelayState) {
$this->syncMachineRelayFromVisibleServices($snapshot, $session, $activateMachine); if ($session->exists()) {
$this->syncMachineRelayFromVisibleServices($snapshot, $session, $activateMachine);
}
} }
return $this->getSessionSummary((int)$session->id); return $this->getSessionSummary((int)$session->id);
@@ -273,28 +303,19 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return; return;
} }
$this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE); $this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE);
$this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE_CLEANER); $this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE_CLEANER);
} catch (\Throwable) { } catch (\Throwable) {
// Best effort only; session completion flow must continue. // Best effort only; session completion flow must continue.
} }
} }
protected function turnOffRelayIfConfiguredAndOn(selfserve_lane $lane, selfserve_lane_relay $relay): void protected function turnOffRelayIfConfigured(selfserve_lane $lane, selfserve_lane_relay $relay): void
{ {
if (!$this->isRelayConfiguredForLane($lane, $relay)) { if (!$this->isRelayConfiguredForLane($lane, $relay)) {
return; return;
} }
try {
$status = $lane->getRelayStatus($relay);
if ((bool)($status['on'] ?? false) !== true) {
return;
}
} catch (\Throwable) {
// If relay status can't be read, still attempt turn-off as best effort.
}
try { try {
$lane->setRelayStatusHard($relay, false); $lane->setRelayStatusHard($relay, false);
} catch (\Throwable) { } catch (\Throwable) {
@@ -339,9 +360,13 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
$allowedServices = $this->normalizeServiceNames( $allowedServices = $this->normalizeServiceNames(
is_array($metadata['allowed_services'] ?? null) ? (array)$metadata['allowed_services'] : [] is_array($metadata['allowed_services'] ?? null) ? (array)$metadata['allowed_services'] : []
); );
$machineAvailable = array_key_exists('machine_available', $metadata) $machineWashEnabled = $this->isMachineWashEnabled();
if (!$machineWashEnabled) {
$allowedServices = $this->withoutMachineService($allowedServices);
}
$machineAvailable = $machineWashEnabled && (array_key_exists('machine_available', $metadata)
? (bool)$metadata['machine_available'] ? (bool)$metadata['machine_available']
: ($lane->exists() && !empty($lane->relay_machine_id->value())); : ($lane->exists() && !empty($lane->relay_machine_id->value())));
$allVisibleQuestionsAnswered = array_key_exists('all_visible_questions_answered', $metadata) $allVisibleQuestionsAnswered = array_key_exists('all_visible_questions_answered', $metadata)
? (bool)$metadata['all_visible_questions_answered'] ? (bool)$metadata['all_visible_questions_answered']
: true; : true;
@@ -387,6 +412,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'events' => $events, 'events' => $events,
'allowed_services' => $allowedServices, 'allowed_services' => $allowedServices,
'machine_available' => $machineAvailable, 'machine_available' => $machineAvailable,
'machine_wash_enabled' => $machineWashEnabled,
'all_visible_questions_answered' => $allVisibleQuestionsAnswered, 'all_visible_questions_answered' => $allVisibleQuestionsAnswered,
'allowed' => (bool)$session->allowed->value(), 'allowed' => (bool)$session->allowed->value(),
'config_version_id' => $metadata['config_version_id'] ?? null, 'config_version_id' => $metadata['config_version_id'] ?? null,
@@ -404,7 +430,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return $this->getSessionSummary((int)$session->id); return $this->getSessionSummary((int)$session->id);
} }
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null): ?array public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true): ?array
{ {
$session = $reg !== null $session = $reg !== null
? $this->findLatestOpenSession($laneId, selfserve::standardize_registration($reg), $customerNumber) ? $this->findLatestOpenSession($laneId, selfserve::standardize_registration($reg), $customerNumber)
@@ -414,8 +440,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return null; return null;
} }
$session->markCompleted($orderId); if (!$session->markCompletedIfOpen($orderId)) {
$this->disableMachineRelayForCompletedWash($laneId); return $this->getSessionSummary((int)$session->id);
}
if ($disableRelays) {
$this->disableMachineRelayForCompletedWash($laneId);
}
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_COMPLETED, [ $this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_COMPLETED, [
'lane_id' => $laneId, 'lane_id' => $laneId,
'reg' => $reg === null ? (string)$session->reg->value() : selfserve::standardize_registration($reg), 'reg' => $reg === null ? (string)$session->reg->value() : selfserve::standardize_registration($reg),
@@ -459,9 +489,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'runtime_before_reset' => $runtimeSnapshot, 'runtime_before_reset' => $runtimeSnapshot,
'forced_at' => date('Y-m-d H:i:s'), 'forced_at' => date('Y-m-d H:i:s'),
]; ];
$session->markForceStopped($orderId, $eventPayload); if (!$session->markForceStoppedIfOpen($orderId, $eventPayload)) {
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_FORCE_STOPPED, $eventPayload); $summary = $this->getSessionSummary((int)$session->id);
$summary = $this->getSessionSummary((int)$session->id); } else {
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_FORCE_STOPPED, $eventPayload);
$summary = $this->getSessionSummary((int)$session->id);
}
} }
$lane->execute(selfserve_lane_command::RESET, new selfserve_lane_command_arguments()); $lane->execute(selfserve_lane_command::RESET, new selfserve_lane_command_arguments());
@@ -659,7 +692,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
} }
} }
$machineAvailable = !empty($lane->relay_machine_id->value()); $machineWashEnabled = $this->isMachineWashEnabled();
if (!$machineWashEnabled) {
$allowedServices = $this->withoutMachineService($allowedServices);
}
$machineAvailable = $machineWashEnabled && !empty($lane->relay_machine_id->value());
$allVisibleQuestionsAnswered = true; $allVisibleQuestionsAnswered = true;
foreach ($visibleQuestions as $question) { foreach ($visibleQuestions as $question) {
if ($question['answer'] === null) { if ($question['answer'] === null) {
@@ -698,8 +736,10 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'tasks' => $visibleTasks, 'tasks' => $visibleTasks,
'allowed_services' => $allowedServices, 'allowed_services' => $allowedServices,
'machine_available' => $machineAvailable, 'machine_available' => $machineAvailable,
'machine_wash_enabled' => $machineWashEnabled,
'all_visible_questions_answered' => $allVisibleQuestionsAnswered, 'all_visible_questions_answered' => $allVisibleQuestionsAnswered,
'allowed' => $machineAllowed, 'allowed' => $machineAllowed,
'blocked_reason' => !$machineWashEnabled ? 'Machine wash is disabled globally.' : null,
'config_version_id' => $publishedConfigVersionId === null ? null : (int)$publishedConfigVersionId, 'config_version_id' => $publishedConfigVersionId === null ? null : (int)$publishedConfigVersionId,
'config_source' => $configSource, 'config_source' => $configSource,
'evaluation_trace' => [ 'evaluation_trace' => [
@@ -723,14 +763,14 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
protected function enableMachineRelayIfAllowed(array $snapshot, selfserve_wash_sessions_o $session): void protected function enableMachineRelayIfAllowed(array $snapshot, selfserve_wash_sessions_o $session): void
{ {
if ((bool)$session->machine_relay_enabled->value() === true) {
return;
}
$laneId = (int)$snapshot['lane']['id']; $laneId = (int)$snapshot['lane']['id'];
$lane = (new selfserve())->lane($laneId); $lane = (new selfserve())->lane($laneId);
$this->enableCleanerRelayForStartedWash($lane); $this->enableCleanerRelayForStartedWash($lane);
if ((bool)$session->machine_relay_enabled->value() === true) {
return;
}
$session->markRelayEnabled(); $session->markRelayEnabled();
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::MACHINE_RELAY_ENABLED, [ $this->logSessionEvent((int)$session->id, selfserve_wash_event_type::MACHINE_RELAY_ENABLED, [
'lane_id' => $laneId, 'lane_id' => $laneId,
@@ -798,6 +838,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
'tasks' => $snapshot['tasks'], 'tasks' => $snapshot['tasks'],
'allowed_services' => $snapshot['allowed_services'], 'allowed_services' => $snapshot['allowed_services'],
'machine_available' => $snapshot['machine_available'], 'machine_available' => $snapshot['machine_available'],
'machine_wash_enabled' => $snapshot['machine_wash_enabled'] ?? true,
'all_visible_questions_answered' => $snapshot['all_visible_questions_answered'], 'all_visible_questions_answered' => $snapshot['all_visible_questions_answered'],
'allowed' => $snapshot['allowed'], 'allowed' => $snapshot['allowed'],
'blocked_reason' => $snapshot['blocked_reason'] ?? null, 'blocked_reason' => $snapshot['blocked_reason'] ?? null,
@@ -2408,11 +2449,20 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
protected function taskUsesProgramPicker(array $task): bool protected function taskUsesProgramPicker(array $task): bool
{ {
return in_array( if (in_array(
'PROGRAM_PICKER', 'PROGRAM_PICKER',
$this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)), $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)),
true true
); )) {
return true;
}
return in_array('program_picker', $this->normalizeButtonList($task['buttons'] ?? null), true);
}
protected function isProgramNumberButton(mixed $button): bool
{
return is_int($button) && $button >= 0 && $button <= 11;
} }
protected function dynamicImageButtonSequenceForTask(array $task): array protected function dynamicImageButtonSequenceForTask(array $task): array
@@ -2422,7 +2472,15 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return $buttons; return $buttons;
} }
return $this->normalizeButtonList(array_merge(['program_picker'], $buttons)); $sequence = ['program_picker'];
foreach ($buttons as $button) {
if ($button === 'program_picker' || $this->isProgramNumberButton($button)) {
continue;
}
$sequence[] = $button;
}
return $this->normalizeButtonList($sequence);
} }
/** /**
@@ -2954,7 +3012,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return array_values(array_filter($questions, static function (array $question) use ($departmentId, $laneId, $vehicleTypeId): bool { return array_values(array_filter($questions, static function (array $question) use ($departmentId, $laneId, $vehicleTypeId): bool {
return (int)($question['department'] ?? 0) === $departmentId return (int)($question['department'] ?? 0) === $departmentId
&& (int)($question['lane'] ?? 0) === $laneId && ((int)($question['lane'] ?? 0) === 0 || (int)($question['lane'] ?? 0) === $laneId)
&& (int)($question['product'] ?? 0) === $vehicleTypeId; && (int)($question['product'] ?? 0) === $vehicleTypeId;
})); }));
} }
@@ -2997,7 +3055,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return array_values(array_filter($conditions, static function (array $condition) use ($departmentId, $laneId, $vehicleTypeId): bool { return array_values(array_filter($conditions, static function (array $condition) use ($departmentId, $laneId, $vehicleTypeId): bool {
return (int)($condition['machine_type_id'] ?? 0) === 0 return (int)($condition['machine_type_id'] ?? 0) === 0
&& (int)($condition['department'] ?? 0) === $departmentId && (int)($condition['department'] ?? 0) === $departmentId
&& (int)($condition['lane'] ?? 0) === $laneId && ((int)($condition['lane'] ?? 0) === 0 || (int)($condition['lane'] ?? 0) === $laneId)
&& (int)($condition['product'] ?? 0) === $vehicleTypeId; && (int)($condition['product'] ?? 0) === $vehicleTypeId;
})); }));
} }
@@ -3041,7 +3099,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return array_values(array_filter($tasks, static function (array $task) use ($departmentId, $laneId, $vehicleTypeId): bool { return array_values(array_filter($tasks, static function (array $task) use ($departmentId, $laneId, $vehicleTypeId): bool {
return (int)($task['machine_type_id'] ?? 0) === 0 return (int)($task['machine_type_id'] ?? 0) === 0
&& (int)($task['department'] ?? 0) === $departmentId && (int)($task['department'] ?? 0) === $departmentId
&& (int)($task['lane'] ?? 0) === $laneId && ((int)($task['lane'] ?? 0) === 0 || (int)($task['lane'] ?? 0) === $laneId)
&& (int)($task['product'] ?? 0) === $vehicleTypeId; && (int)($task['product'] ?? 0) === $vehicleTypeId;
})); }));
} }
@@ -3180,11 +3238,92 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
(new selfserve_wash_session_events_o())->add($sessionId, $eventType, $payload); (new selfserve_wash_session_events_o())->add($sessionId, $eventType, $payload);
} }
/**
* @param callable():array<string,mixed> $callback
* @return array<string,mixed>
*/
protected function withSessionMutationLock(int $laneId, string $reg, ?int $customerNumber, callable $callback): array
{
$lockKey = $this->sessionMutationLockKey($laneId, $reg, $customerNumber);
$lock = $this->acquireSessionMutationLock($lockKey);
try {
return $callback();
} finally {
$this->releaseSessionMutationLock($lock);
}
}
/**
* @return array{driver:string,key:string,token:?string}
*/
protected function acquireSessionMutationLock(string $lockKey): array
{
if (defined('redis') && method_exists(redis, 'set_if_absent_with_expiration')) {
$token = bin2hex(random_bytes(16));
if (!redis->set_if_absent_with_expiration($lockKey, $token, 15)) {
throw new \RuntimeException('Self-serve wash session is busy. Try again.');
}
return [
'driver' => 'redis',
'key' => $lockKey,
'token' => $token,
];
}
global $db;
$result = $db->query("SELECT GET_LOCK('" . $db->escape_string($lockKey) . "', 5) AS acquired");
$row = $db->fetch_assoc($result);
if ((int)($row['acquired'] ?? 0) !== 1) {
throw new \RuntimeException('Self-serve wash session is busy. Try again.');
}
return [
'driver' => 'mysql',
'key' => $lockKey,
'token' => null,
];
}
/**
* @param array{driver:string,key:string,token:?string} $lock
*/
protected function releaseSessionMutationLock(array $lock): void
{
try {
if ($lock['driver'] === 'redis' && defined('redis')) {
if (method_exists(redis, 'get') && redis->get($lock['key']) !== $lock['token']) {
return;
}
if (method_exists(redis, 'delete')) {
redis->delete($lock['key']);
}
return;
}
if ($lock['driver'] === 'mysql') {
global $db;
$db->query("SELECT RELEASE_LOCK('" . $db->escape_string($lock['key']) . "')");
}
} catch (\Throwable) {
// Locks have TTLs or connection scope; release failures must not mask API results.
}
}
protected function sessionMutationLockKey(int $laneId, string $reg, ?int $customerNumber): string
{
return 'selfserve_session_mutation:' . (int)$laneId . ':' . sha1(
selfserve::standardize_registration($reg) . ':' . ($customerNumber === null ? 'anon' : (string)(int)$customerNumber)
);
}
protected function buildSessionMetadata(array $snapshot): array protected function buildSessionMetadata(array $snapshot): array
{ {
return [ return [
'allowed_services' => $snapshot['allowed_services'], 'allowed_services' => $snapshot['allowed_services'],
'machine_available' => (bool)$snapshot['machine_available'], 'machine_available' => (bool)$snapshot['machine_available'],
'machine_wash_enabled' => (bool)($snapshot['machine_wash_enabled'] ?? true),
'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'], 'all_visible_questions_answered' => (bool)$snapshot['all_visible_questions_answered'],
'config_version_id' => $snapshot['config_version_id'] ?? null, 'config_version_id' => $snapshot['config_version_id'] ?? null,
'evaluation_trace' => $snapshot['evaluation_trace'] ?? null, 'evaluation_trace' => $snapshot['evaluation_trace'] ?? null,
@@ -3483,6 +3622,27 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
)); ));
} }
protected function isMachineWashEnabled(): bool
{
try {
return (new selfserve())->config->machine_wash_enabled->isTrue();
} catch (\Throwable) {
return true;
}
}
/**
* @param array<int,string> $services
* @return array<int,string>
*/
protected function withoutMachineService(array $services): array
{
return array_values(array_filter(
$this->normalizeServiceNames($services),
static fn(string $service): bool => $service !== selfserve_lane_services::MACHINE->name
));
}
protected function taskUsesMachineControls(array $task): bool protected function taskUsesMachineControls(array $task): bool
{ {
if (in_array(selfserve_lane_services::MACHINE->name, $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)), true)) { if (in_array(selfserve_lane_services::MACHINE->name, $this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)), true)) {
@@ -0,0 +1,32 @@
<?php
namespace modules\selfserve\config;
use Exception;
use traits\module_config_variable;
class selfserve_dynamic_image_size_c
{
use module_config_variable;
public const SIZE_ORIGINAL = 'original';
public const SIZE_RELEVANT = 'relevant';
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'selfserve',
'dynamic_image_size',
'string',
true,
[self::SIZE_ORIGINAL, self::SIZE_RELEVANT],
'Whether self-serve dynamic images are served in the original rendered size or resized to the relevant terminal size',
self::SIZE_RELEVANT,
false,
self::SIZE_ORIGINAL
);
}
}
@@ -0,0 +1,29 @@
<?php
namespace modules\selfserve\config;
use Exception;
use traits\module_config_variable;
class selfserve_machine_wash_enabled_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'selfserve',
'machine_wash_enabled',
'bool',
true,
null,
'Whether machine wash is available in customer-facing self-serve flows',
'1',
false,
true
);
}
}
@@ -14,7 +14,7 @@ interface selfserve_wash_flow_i
public function getLatestSessionSummary(int $laneId, string $reg): array; public function getLatestSessionSummary(int $laneId, string $reg): array;
public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null): ?array; public function completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true): ?array;
public function forceStopLane(int $laneId, ?int $sessionId = null, bool $bill = false, ?string $reason = null, ?int $userId = null): array; public function forceStopLane(int $laneId, ?int $sessionId = null, bool $bill = false, ?string $reason = null, ?int $userId = null): array;
} }
@@ -439,7 +439,7 @@ Public methods:
| `recordMachineStartWebhook(int $laneId, ?string $reg = null, array $payload = [])` | The machine button or hardware event fired. | Full session summary after the machine-start event. | | `recordMachineStartWebhook(int $laneId, ?string $reg = null, array $payload = [])` | The machine button or hardware event fired. | Full session summary after the machine-start event. |
| `getSessionSummary(int $sessionId)` | You have a session id already. | Full session summary. | | `getSessionSummary(int $sessionId)` | You have a session id already. | Full session summary. |
| `getLatestSessionSummary(int $laneId, string $reg)` | You want the latest session for a lane and vehicle. | Full session summary. | | `getLatestSessionSummary(int $laneId, string $reg)` | You want the latest session for a lane and vehicle. | Full session summary. |
| `completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null)` | STOP has finished and you want to close the latest open session. | Full summary, or `null` if no open session exists. | | `completeLatestSessionForLane(int $laneId, ?string $reg = null, ?int $customerNumber = null, ?int $orderId = null, bool $disableRelays = true)` | STOP has finished and you want to close the latest open session. Normal STOP passes `false` because it already disabled relays before opening the exit port. | Full summary, or `null` if no open session exists. |
Key implementation details: Key implementation details:
@@ -2,10 +2,14 @@
namespace modules\selfserve; namespace modules\selfserve;
require_once WD . '/modules/selfserve/config/selfserve_enabled_c.php'; require_once WD . '/modules/selfserve/config/selfserve_enabled_c.php';
require_once WD . '/modules/selfserve/config/selfserve_machine_wash_enabled_c.php';
require_once WD . '/modules/selfserve/config/selfserve_minute_product_c.php'; require_once WD . '/modules/selfserve/config/selfserve_minute_product_c.php';
require_once WD . '/modules/selfserve/config/selfserve_machine_wash_minutes_included_c.php'; require_once WD . '/modules/selfserve/config/selfserve_machine_wash_minutes_included_c.php';
require_once WD . '/modules/selfserve/config/selfserve_dynamic_image_size_c.php';
use modules\selfserve\config\selfserve_dynamic_image_size_c;
use modules\selfserve\config\selfserve_enabled_c; use modules\selfserve\config\selfserve_enabled_c;
use modules\selfserve\config\selfserve_machine_wash_enabled_c;
use modules\selfserve\config\selfserve_machine_wash_minutes_included_c; use modules\selfserve\config\selfserve_machine_wash_minutes_included_c;
use modules\selfserve\config\selfserve_minute_product_c; use modules\selfserve\config\selfserve_minute_product_c;
use traits\module_config_t; use traits\module_config_t;
@@ -19,6 +23,11 @@ class selfserve_c
* @var selfserve_enabled_c $enabled * @var selfserve_enabled_c $enabled
*/ */
public selfserve_enabled_c $enabled; public selfserve_enabled_c $enabled;
/**
* Whether machine wash is available in customer-facing self-serve flows
* @var selfserve_machine_wash_enabled_c $machine_wash_enabled
*/
public selfserve_machine_wash_enabled_c $machine_wash_enabled;
/** /**
* The product ID used for minute-based self-serve billing * The product ID used for minute-based self-serve billing
* @var selfserve_minute_product_c $minute_product * @var selfserve_minute_product_c $minute_product
@@ -29,17 +38,26 @@ class selfserve_c
* @var selfserve_machine_wash_minutes_included_c $machine_wash_minutes_included * @var selfserve_machine_wash_minutes_included_c $machine_wash_minutes_included
*/ */
public selfserve_machine_wash_minutes_included_c $machine_wash_minutes_included; public selfserve_machine_wash_minutes_included_c $machine_wash_minutes_included;
/**
* Dynamic image output size mode for self-serve terminals
* @var selfserve_dynamic_image_size_c $dynamic_image_size
*/
public selfserve_dynamic_image_size_c $dynamic_image_size;
public function __construct() public function __construct()
{ {
$this->setupConfig('selfserve'); $this->setupConfig('selfserve');
$this->allowUpdate([ $this->allowUpdate([
selfserve_enabled_c::class, selfserve_enabled_c::class,
selfserve_machine_wash_enabled_c::class,
selfserve_minute_product_c::class, selfserve_minute_product_c::class,
selfserve_machine_wash_minutes_included_c::class selfserve_machine_wash_minutes_included_c::class,
selfserve_dynamic_image_size_c::class
]); ]);
$this->enabled = new selfserve_enabled_c(); $this->enabled = new selfserve_enabled_c();
$this->machine_wash_enabled = new selfserve_machine_wash_enabled_c();
$this->minute_product = new selfserve_minute_product_c(); $this->minute_product = new selfserve_minute_product_c();
$this->machine_wash_minutes_included = new selfserve_machine_wash_minutes_included_c(); $this->machine_wash_minutes_included = new selfserve_machine_wash_minutes_included_c();
$this->dynamic_image_size = new selfserve_dynamic_image_size_c();
} }
} }
@@ -9,6 +9,7 @@ trait selfserve_lane_cache_t
{ {
const CACHE_SELFSERVE_PREFIX = 'selfserve_lane_'; const CACHE_SELFSERVE_PREFIX = 'selfserve_lane_';
const CACHE_SELFSERVE_LANE_KEY_STATUS = self::CACHE_SELFSERVE_PREFIX . 'status'; const CACHE_SELFSERVE_LANE_KEY_STATUS = self::CACHE_SELFSERVE_PREFIX . 'status';
const CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT = self::CACHE_SELFSERVE_PREFIX . 'status_audit';
const CACHE_SELFSERVE_LANE_KEY_STATE = self::CACHE_SELFSERVE_PREFIX . 'state'; const CACHE_SELFSERVE_LANE_KEY_STATE = self::CACHE_SELFSERVE_PREFIX . 'state';
const CACHE_SELFSERVE_LANE_KEY_MODE = self::CACHE_SELFSERVE_PREFIX . 'mode'; const CACHE_SELFSERVE_LANE_KEY_MODE = self::CACHE_SELFSERVE_PREFIX . 'mode';
const CACHE_SELFSERVE_LANE_KEY_WASH_START_TIME = self::CACHE_SELFSERVE_PREFIX . 'wash_start_time'; const CACHE_SELFSERVE_LANE_KEY_WASH_START_TIME = self::CACHE_SELFSERVE_PREFIX . 'wash_start_time';
@@ -77,4 +78,4 @@ trait selfserve_lane_cache_t
redis->delete($this->getLaneCacheKey($laneId, $property)); redis->delete($this->getLaneCacheKey($laneId, $property));
return $this; return $this;
} }
} }

Some files were not shown because too many files have changed in this diff Show More