Compare commits

...
Author SHA1 Message Date
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
210 changed files with 145315 additions and 11314 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
+7 -6
View File
@@ -9,17 +9,18 @@ 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, default]
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 - name: Mark repository as safe for Git
run: git config --global --add safe.directory "$GITHUB_WORKSPACE" run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: Prepare Qodana cache directories - name: Prepare Qodana cache directories
@@ -48,4 +49,4 @@ jobs:
- name: 'Skip Qodana Scan (missing cloud token)' - name: 'Skip Qodana Scan (missing cloud token)'
if: ${{ steps.qodana-token.outputs.present != 'true' }} if: ${{ steps.qodana-token.outputs.present != 'true' }}
run: echo "Skipping Qodana because QODANA_TOKEN is not configured for this repository." run: echo "Skipping Qodana because QODANA_TOKEN is not configured."
+51 -3
View File
@@ -164,7 +164,30 @@ jobs:
| 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 -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: >
@@ -267,11 +290,36 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1) test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
curl --fail --show-error --silent \ 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" \ -X POST "$RELEASE_MANAGER_GATE_URL" \
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \ -H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
-H "Content-Type: application/json" \ -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\":[]}" --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: env:
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }} 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_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
+2
View File
@@ -10,5 +10,7 @@
/.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
+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": [
{ {
+2988 -200
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
+45 -4
View File
@@ -51,6 +51,39 @@ 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'
}
cleanup() { cleanup() {
status="$?" status="$?"
collect_logs "$status" collect_logs "$status"
@@ -70,7 +103,7 @@ cleanup() {
} }
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
docker compose $compose_files up -d redis mysql-debug php1 retry_command "${PHP_CI_DOCKER_RETRIES:-3}" docker compose $compose_files up -d 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
@@ -95,8 +128,16 @@ tar \
-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 -C /var/www/html -xf -
docker compose $compose_files exec -T php1 sh -lc \ docker compose $compose_files exec -T php1 sh -lc 'rm -rf /var/www/repo-root && mkdir -p /var/www/repo-root'
'cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress' tar \
-cf - \
Dockerfile \
Dockerfile.coolify-api \
services/php/Dockerfile \
services/php/php-fpm-pool.conf \
| docker compose $compose_files exec -T php1 tar -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 test:ci:$suite" "cd /var/www/html && PLENO_REPO_ROOT_FOR_TESTS=/var/www/repo-root composer test:ci:$suite"
+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
+32 -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 = {}) {
@@ -754,6 +759,15 @@ async function fetchArtifactBuffer(url, expectedSha256, label, fetchImpl = fetch
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 +775,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`);
} }
@@ -1785,6 +1799,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 +1937,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 {
+132 -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;
@@ -270,6 +276,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 +325,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 +369,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 +744,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 +1003,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");
+40 -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;
@@ -460,25 +487,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 +542,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 +576,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 +643,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 +744,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 +787,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 || {},
+107 -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",
@@ -743,9 +838,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
@@ -12204,3 +12204,251 @@
[Tue May 26 09:31:07 2026] 127.0.0.1:38284 Closing [Tue May 26 09:31:07 2026] 127.0.0.1:38284 Closing
[Tue May 26 09:31:08 2026] 127.0.0.1:38290 Accepted [Tue May 26 09:31:08 2026] 127.0.0.1:38290 Accepted
[Tue May 26 09:31:12 2026] 127.0.0.1:38290 Closing [Tue May 26 09:31:12 2026] 127.0.0.1:38290 Closing
[Tue Jun 2 12:55:52 2026] PHP 8.2.15 Development Server (http://127.0.0.1:45377) started
[Tue Jun 2 12:55:52 2026] 127.0.0.1:52720 Accepted
[Tue Jun 2 12:55:52 2026] 127.0.0.1:52720 Closing
[Tue Jun 2 12:55:52 2026] 127.0.0.1:52734 Accepted
[Tue Jun 2 12:55:52 2026] 127.0.0.1:52734 Closing
[Tue Jun 2 12:55:52 2026] 127.0.0.1:52742 Accepted
[Tue Jun 2 12:55:52 2026] 127.0.0.1:52742 Closing
[Tue Jun 2 12:55:52 2026] 127.0.0.1:52750 Accepted
[Tue Jun 2 12:55:52 2026] 127.0.0.1:52750 Closing
[Tue Jun 2 12:55:52 2026] 127.0.0.1:52766 Accepted
[Tue Jun 2 12:55:52 2026] 127.0.0.1:52766 Closing
[Tue Jun 2 12:55:53 2026] 127.0.0.1:52770 Accepted
[Tue Jun 2 12:55:53 2026] 127.0.0.1:52770 Closing
[Tue Jun 2 12:55:53 2026] 127.0.0.1:52784 Accepted
[Tue Jun 2 12:55:53 2026] 127.0.0.1:52784 Closing
[Tue Jun 2 12:55:53 2026] 127.0.0.1:52792 Accepted
[Tue Jun 2 12:55:53 2026] 127.0.0.1:52792 Closing
[Tue Jun 2 12:55:53 2026] 127.0.0.1:52802 Accepted
[Tue Jun 2 12:55:53 2026] 127.0.0.1:52802 Closing
[Tue Jun 2 12:59:15 2026] PHP 8.2.15 Development Server (http://127.0.0.1:42651) started
[Tue Jun 2 12:59:15 2026] 127.0.0.1:41826 Accepted
[Tue Jun 2 12:59:15 2026] 127.0.0.1:41826 Closing
[Tue Jun 2 12:59:15 2026] 127.0.0.1:41842 Accepted
[Tue Jun 2 12:59:15 2026] 127.0.0.1:41842 Closing
[Tue Jun 2 12:59:15 2026] 127.0.0.1:41850 Accepted
[Tue Jun 2 12:59:15 2026] 127.0.0.1:41850 Closing
[Tue Jun 2 12:59:15 2026] 127.0.0.1:41860 Accepted
[Tue Jun 2 12:59:15 2026] 127.0.0.1:41860 Closing
[Tue Jun 2 12:59:15 2026] 127.0.0.1:41874 Accepted
[Tue Jun 2 12:59:15 2026] 127.0.0.1:41874 Closing
[Tue Jun 2 12:59:16 2026] 127.0.0.1:41884 Accepted
[Tue Jun 2 12:59:16 2026] 127.0.0.1:41884 Closing
[Tue Jun 2 12:59:16 2026] 127.0.0.1:41894 Accepted
[Tue Jun 2 12:59:16 2026] 127.0.0.1:41894 Closing
[Tue Jun 2 12:59:16 2026] 127.0.0.1:41902 Accepted
[Tue Jun 2 12:59:16 2026] 127.0.0.1:41902 Closing
[Tue Jun 2 12:59:16 2026] 127.0.0.1:41916 Accepted
[Tue Jun 2 12:59:37 2026] 127.0.0.1:41916 Closing
[Tue Jun 2 13:02:13 2026] PHP 8.2.15 Development Server (http://127.0.0.1:38777) started
[Tue Jun 2 13:02:13 2026] 127.0.0.1:37238 Accepted
[Tue Jun 2 13:02:13 2026] 127.0.0.1:37238 Closing
[Tue Jun 2 13:02:13 2026] 127.0.0.1:37252 Accepted
[Tue Jun 2 13:02:13 2026] 127.0.0.1:37252 Closing
[Tue Jun 2 13:02:13 2026] 127.0.0.1:37256 Accepted
[Tue Jun 2 13:02:13 2026] 127.0.0.1:37256 Closing
[Tue Jun 2 13:02:15 2026] 127.0.0.1:35516 Accepted
[Tue Jun 2 13:02:15 2026] 127.0.0.1:35516 Closing
[Tue Jun 2 13:02:15 2026] 127.0.0.1:35518 Accepted
[Tue Jun 2 13:02:15 2026] 127.0.0.1:35518 Closing
[Tue Jun 2 13:02:15 2026] 127.0.0.1:35528 Accepted
[Tue Jun 2 13:02:15 2026] 127.0.0.1:35528 Closing
[Tue Jun 2 13:02:16 2026] 127.0.0.1:35544 Accepted
[Tue Jun 2 13:02:16 2026] 127.0.0.1:35544 Closing
[Tue Jun 2 13:02:16 2026] 127.0.0.1:35552 Accepted
[Tue Jun 2 13:02:16 2026] 127.0.0.1:35552 Closing
[Tue Jun 2 13:02:16 2026] 127.0.0.1:35568 Accepted
[Tue Jun 2 13:02:16 2026] 127.0.0.1:35568 Closing
[Tue Jun 2 13:02:16 2026] 127.0.0.1:35582 Accepted
[Tue Jun 2 13:02:16 2026] 127.0.0.1:35582 Closing
[Tue Jun 2 13:02:16 2026] 127.0.0.1:35586 Accepted
[Tue Jun 2 13:02:17 2026] 127.0.0.1:35586 Closing
[Tue Jun 2 13:02:17 2026] 127.0.0.1:35588 Accepted
[Tue Jun 2 13:02:17 2026] 127.0.0.1:35588 Closing
[Tue Jun 2 13:02:19 2026] 127.0.0.1:35596 Accepted
[Tue Jun 2 13:02:20 2026] 127.0.0.1:35596 Closing
[Tue Jun 2 13:02:20 2026] 127.0.0.1:35600 Accepted
[Tue Jun 2 13:02:20 2026] 127.0.0.1:35600 Closing
[Tue Jun 2 13:02:20 2026] 127.0.0.1:35612 Accepted
[Tue Jun 2 13:02:20 2026] 127.0.0.1:35612 Closing
[Tue Jun 2 13:02:46 2026] PHP 8.2.15 Development Server (http://127.0.0.1:39573) started
[Tue Jun 2 13:02:46 2026] 127.0.0.1:43488 Accepted
[Tue Jun 2 13:02:46 2026] 127.0.0.1:43488 Closing
[Tue Jun 2 13:02:46 2026] 127.0.0.1:43494 Accepted
[Tue Jun 2 13:02:47 2026] 127.0.0.1:43494 Closing
[Tue Jun 2 13:02:47 2026] 127.0.0.1:43502 Accepted
[Tue Jun 2 13:02:47 2026] 127.0.0.1:43502 Closing
[Tue Jun 2 13:02:47 2026] 127.0.0.1:43504 Accepted
[Tue Jun 2 13:02:47 2026] 127.0.0.1:43504 Closing
[Tue Jun 2 13:02:47 2026] 127.0.0.1:43516 Accepted
[Tue Jun 2 13:02:47 2026] 127.0.0.1:43516 Closing
[Tue Jun 2 13:02:53 2026] 127.0.0.1:43530 Accepted
[Tue Jun 2 13:02:53 2026] 127.0.0.1:43530 Closing
[Tue Jun 2 13:02:53 2026] 127.0.0.1:43540 Accepted
[Tue Jun 2 13:02:53 2026] 127.0.0.1:43540 Closing
[Tue Jun 2 13:02:53 2026] 127.0.0.1:43550 Accepted
[Tue Jun 2 13:02:53 2026] 127.0.0.1:43550 Closing
[Tue Jun 2 13:02:53 2026] 127.0.0.1:43562 Accepted
[Tue Jun 2 13:02:53 2026] 127.0.0.1:43562 Closing
[Tue Jun 2 13:02:53 2026] 127.0.0.1:43578 Accepted
[Tue Jun 2 13:02:53 2026] 127.0.0.1:43578 Closing
[Tue Jun 2 13:02:54 2026] 127.0.0.1:51950 Accepted
[Tue Jun 2 13:02:54 2026] 127.0.0.1:51950 Closing
[Tue Jun 2 13:02:54 2026] 127.0.0.1:51952 Accepted
[Tue Jun 2 13:02:54 2026] 127.0.0.1:51952 Closing
[Tue Jun 2 13:02:54 2026] 127.0.0.1:51964 Accepted
[Tue Jun 2 13:02:54 2026] 127.0.0.1:51964 Closing
[Tue Jun 2 13:02:54 2026] 127.0.0.1:51978 Accepted
[Tue Jun 2 13:02:54 2026] 127.0.0.1:51978 Closing
[Tue Jun 2 13:02:55 2026] 127.0.0.1:51994 Accepted
[Tue Jun 2 13:02:55 2026] 127.0.0.1:51994 Closing
[Tue Jun 2 13:25:35 2026] PHP 8.2.15 Development Server (http://127.0.0.1:40811) started
[Tue Jun 2 13:25:35 2026] 127.0.0.1:43736 Accepted
[Tue Jun 2 13:25:35 2026] 127.0.0.1:43736 Closing
[Tue Jun 2 13:25:35 2026] 127.0.0.1:43740 Accepted
[Tue Jun 2 13:25:35 2026] 127.0.0.1:43740 Closing
[Tue Jun 2 13:25:35 2026] 127.0.0.1:43754 Accepted
[Tue Jun 2 13:25:35 2026] 127.0.0.1:43754 Closing
[Tue Jun 2 13:25:36 2026] 127.0.0.1:43768 Accepted
[Tue Jun 2 13:25:36 2026] 127.0.0.1:43768 Closing
[Tue Jun 2 13:25:36 2026] 127.0.0.1:43776 Accepted
[Tue Jun 2 13:25:36 2026] 127.0.0.1:43776 Closing
[Tue Jun 2 13:25:52 2026] 127.0.0.1:57442 Accepted
[Tue Jun 2 13:25:52 2026] 127.0.0.1:57442 Closing
[Tue Jun 2 13:25:52 2026] 127.0.0.1:57452 Accepted
[Tue Jun 2 13:25:53 2026] 127.0.0.1:57452 Closing
[Tue Jun 2 13:25:53 2026] 127.0.0.1:57460 Accepted
[Tue Jun 2 13:25:53 2026] 127.0.0.1:57460 Closing
[Tue Jun 2 13:25:53 2026] 127.0.0.1:57472 Accepted
[Tue Jun 2 13:25:53 2026] 127.0.0.1:57472 Closing
[Tue Jun 2 13:25:53 2026] 127.0.0.1:57486 Accepted
[Tue Jun 2 13:25:53 2026] 127.0.0.1:57486 Closing
[Tue Jun 2 13:25:53 2026] 127.0.0.1:57492 Accepted
[Tue Jun 2 13:25:53 2026] 127.0.0.1:57492 Closing
[Tue Jun 2 13:25:53 2026] 127.0.0.1:57508 Accepted
[Tue Jun 2 13:25:53 2026] 127.0.0.1:57508 Closing
[Tue Jun 2 13:25:53 2026] 127.0.0.1:57522 Accepted
[Tue Jun 2 13:25:54 2026] 127.0.0.1:57522 Closing
[Tue Jun 2 13:25:54 2026] 127.0.0.1:60940 Accepted
[Tue Jun 2 13:25:54 2026] 127.0.0.1:60940 Closing
[Tue Jun 2 13:25:54 2026] 127.0.0.1:60950 Accepted
[Tue Jun 2 13:25:54 2026] 127.0.0.1:60950 Closing
[Tue Jun 2 13:44:42 2026] PHP 8.2.15 Development Server (http://127.0.0.1:37125) started
[Tue Jun 2 13:44:42 2026] 127.0.0.1:45382 Accepted
[Tue Jun 2 13:44:42 2026] 127.0.0.1:45382 Closing
[Tue Jun 2 13:44:42 2026] 127.0.0.1:45388 Accepted
[Tue Jun 2 13:44:42 2026] 127.0.0.1:45388 Closing
[Tue Jun 2 13:44:42 2026] 127.0.0.1:45398 Accepted
[Tue Jun 2 13:44:43 2026] 127.0.0.1:45398 Closing
[Tue Jun 2 13:44:43 2026] 127.0.0.1:45410 Accepted
[Tue Jun 2 13:44:46 2026] 127.0.0.1:45410 Closing
[Tue Jun 2 13:44:46 2026] 127.0.0.1:53928 Accepted
[Tue Jun 2 13:44:46 2026] 127.0.0.1:53928 Closing
[Tue Jun 2 13:44:51 2026] PHP 8.2.15 Development Server (http://127.0.0.1:34643) started
[Tue Jun 2 13:44:51 2026] 127.0.0.1:46776 Accepted
[Tue Jun 2 13:44:51 2026] 127.0.0.1:46776 Closing
[Tue Jun 2 13:44:51 2026] 127.0.0.1:46782 Accepted
[Tue Jun 2 13:44:51 2026] 127.0.0.1:46782 Closing
[Tue Jun 2 13:44:51 2026] 127.0.0.1:46796 Accepted
[Tue Jun 2 13:44:51 2026] 127.0.0.1:46796 Closing
[Tue Jun 2 13:44:51 2026] 127.0.0.1:46806 Accepted
[Tue Jun 2 13:44:51 2026] 127.0.0.1:46806 Closing
[Tue Jun 2 13:44:51 2026] 127.0.0.1:46816 Accepted
[Tue Jun 2 13:44:51 2026] 127.0.0.1:46816 Closing
[Tue Jun 2 13:44:52 2026] 127.0.0.1:46824 Accepted
[Tue Jun 2 13:44:52 2026] 127.0.0.1:46824 Closing
[Tue Jun 2 13:44:53 2026] 127.0.0.1:46836 Accepted
[Tue Jun 2 13:44:53 2026] 127.0.0.1:46836 Closing
[Tue Jun 2 13:44:53 2026] 127.0.0.1:46846 Accepted
[Tue Jun 2 13:44:53 2026] 127.0.0.1:46846 Closing
[Tue Jun 2 13:44:53 2026] 127.0.0.1:46848 Accepted
[Tue Jun 2 13:44:53 2026] 127.0.0.1:46848 Closing
[Tue Jun 2 13:44:54 2026] 127.0.0.1:45254 Accepted
[Tue Jun 2 13:44:54 2026] 127.0.0.1:45254 Closing
[Tue Jun 2 13:44:54 2026] 127.0.0.1:45264 Accepted
[Tue Jun 2 13:44:54 2026] 127.0.0.1:45264 Closing
[Tue Jun 2 13:44:54 2026] 127.0.0.1:45276 Accepted
[Tue Jun 2 13:44:54 2026] 127.0.0.1:45276 Closing
[Tue Jun 2 13:44:54 2026] 127.0.0.1:45282 Accepted
[Tue Jun 2 13:44:54 2026] 127.0.0.1:45282 Closing
[Tue Jun 2 13:44:54 2026] 127.0.0.1:45288 Accepted
[Tue Jun 2 13:44:54 2026] 127.0.0.1:45288 Closing
[Tue Jun 2 13:44:54 2026] 127.0.0.1:45296 Accepted
[Tue Jun 2 13:44:54 2026] 127.0.0.1:45296 Closing
[Tue Jun 2 13:44:55 2026] 127.0.0.1:45312 Accepted
[Tue Jun 2 13:44:55 2026] 127.0.0.1:45312 Closing
[Tue Jun 2 13:44:55 2026] 127.0.0.1:45314 Accepted
[Tue Jun 2 13:44:55 2026] 127.0.0.1:45314 Closing
[Tue Jun 2 13:44:55 2026] 127.0.0.1:45328 Accepted
[Tue Jun 2 13:44:55 2026] 127.0.0.1:45328 Closing
[Tue Jun 2 13:44:55 2026] 127.0.0.1:45330 Accepted
[Tue Jun 2 13:44:55 2026] 127.0.0.1:45330 Closing
[Tue Jun 2 14:14:22 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41283) started
[Tue Jun 2 14:14:22 2026] 127.0.0.1:54804 Accepted
[Tue Jun 2 14:14:22 2026] 127.0.0.1:54804 Closing
[Tue Jun 2 14:14:22 2026] 127.0.0.1:54812 Accepted
[Tue Jun 2 14:14:22 2026] 127.0.0.1:54812 Closing
[Tue Jun 2 14:14:22 2026] 127.0.0.1:54816 Accepted
[Tue Jun 2 14:14:22 2026] 127.0.0.1:54816 Closing
[Tue Jun 2 14:14:22 2026] 127.0.0.1:54826 Accepted
[Tue Jun 2 14:14:23 2026] 127.0.0.1:54826 Closing
[Tue Jun 2 14:14:23 2026] 127.0.0.1:54828 Accepted
[Tue Jun 2 14:14:25 2026] 127.0.0.1:54828 Closing
[Tue Jun 2 14:14:25 2026] 127.0.0.1:45644 Accepted
[Tue Jun 2 14:14:25 2026] 127.0.0.1:45644 Closing
[Tue Jun 2 14:14:42 2026] PHP 8.2.15 Development Server (http://127.0.0.1:40157) started
[Tue Jun 2 14:14:42 2026] 127.0.0.1:46410 Accepted
[Tue Jun 2 14:14:42 2026] 127.0.0.1:46410 Closing
[Tue Jun 2 14:14:42 2026] 127.0.0.1:46414 Accepted
[Tue Jun 2 14:14:42 2026] 127.0.0.1:46414 Closing
[Tue Jun 2 14:14:42 2026] 127.0.0.1:46424 Accepted
[Tue Jun 2 14:14:42 2026] 127.0.0.1:46424 Closing
[Tue Jun 2 14:14:42 2026] 127.0.0.1:46440 Accepted
[Tue Jun 2 14:14:42 2026] 127.0.0.1:46440 Closing
[Tue Jun 2 14:14:42 2026] 127.0.0.1:46442 Accepted
[Tue Jun 2 14:14:42 2026] 127.0.0.1:46442 Closing
[Tue Jun 2 14:14:43 2026] 127.0.0.1:46458 Accepted
[Tue Jun 2 14:14:43 2026] 127.0.0.1:46458 Closing
[Tue Jun 2 14:16:10 2026] PHP 8.2.15 Development Server (http://127.0.0.1:38915) started
[Tue Jun 2 14:16:10 2026] 127.0.0.1:54470 Accepted
[Tue Jun 2 14:16:10 2026] 127.0.0.1:54470 Closing
[Tue Jun 2 14:16:10 2026] 127.0.0.1:54482 Accepted
[Tue Jun 2 14:16:11 2026] 127.0.0.1:54482 Closing
[Tue Jun 2 14:16:11 2026] 127.0.0.1:54492 Accepted
[Tue Jun 2 14:16:11 2026] 127.0.0.1:54492 Closing
[Tue Jun 2 14:16:11 2026] 127.0.0.1:54498 Accepted
[Tue Jun 2 14:16:11 2026] 127.0.0.1:54498 Closing
[Tue Jun 2 14:16:11 2026] 127.0.0.1:54508 Accepted
[Tue Jun 2 14:16:11 2026] 127.0.0.1:54508 Closing
[Tue Jun 2 14:16:24 2026] 127.0.0.1:48044 Accepted
[Tue Jun 2 14:16:24 2026] 127.0.0.1:48044 Closing
[Tue Jun 2 14:16:25 2026] 127.0.0.1:48060 Accepted
[Tue Jun 2 14:16:25 2026] 127.0.0.1:48060 Closing
[Tue Jun 2 14:16:27 2026] 127.0.0.1:48072 Accepted
[Tue Jun 2 14:16:27 2026] 127.0.0.1:48072 Closing
[Tue Jun 2 14:16:27 2026] 127.0.0.1:48088 Accepted
[Tue Jun 2 14:16:28 2026] 127.0.0.1:48088 Closing
[Tue Jun 2 14:16:28 2026] 127.0.0.1:48094 Accepted
[Tue Jun 2 14:16:28 2026] 127.0.0.1:48094 Closing
[Tue Jun 2 14:16:34 2026] 127.0.0.1:55000 Accepted
[Tue Jun 2 14:16:34 2026] 127.0.0.1:55000 Closing
[Tue Jun 2 14:16:34 2026] 127.0.0.1:55006 Accepted
[Tue Jun 2 14:16:34 2026] 127.0.0.1:55006 Closing
[Tue Jun 2 14:16:34 2026] 127.0.0.1:55016 Accepted
[Tue Jun 2 14:16:34 2026] 127.0.0.1:55016 Closing
[Tue Jun 2 14:16:34 2026] 127.0.0.1:55018 Accepted
[Tue Jun 2 14:16:34 2026] 127.0.0.1:55018 Closing
[Tue Jun 2 14:16:35 2026] 127.0.0.1:55022 Accepted
[Tue Jun 2 14:16:35 2026] 127.0.0.1:55022 Closing
[Tue Jun 2 14:16:35 2026] 127.0.0.1:55032 Accepted
[Tue Jun 2 14:16:35 2026] 127.0.0.1:55032 Closing
[Tue Jun 2 14:16:35 2026] 127.0.0.1:55048 Accepted
[Tue Jun 2 14:16:35 2026] 127.0.0.1:55048 Closing
[Tue Jun 2 14:16:35 2026] 127.0.0.1:55060 Accepted
[Tue Jun 2 14:16:35 2026] 127.0.0.1:55060 Closing
[Tue Jun 2 14:16:35 2026] 127.0.0.1:55074 Accepted
[Tue Jun 2 14:16:35 2026] 127.0.0.1:55074 Closing
[Tue Jun 2 14:17:06 2026] 127.0.0.1:34490 Accepted
[Tue Jun 2 14:17:06 2026] 127.0.0.1:34490 Closing
+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);
@@ -25,6 +25,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
@@ -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,
);
}
}
} }
@@ -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
+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;
} }
+350 -32
View File
@@ -35,6 +35,9 @@ class release_manager
private const DEFAULT_COOLIFY_ENVIRONMENT_CHANNELS = ['stable', 'production', 'prod']; private const DEFAULT_COOLIFY_ENVIRONMENT_CHANNELS = ['stable', 'production', 'prod'];
private const DEFAULT_COOLIFY_APPLICATION_PORT = '80'; private const DEFAULT_COOLIFY_APPLICATION_PORT = '80';
private const DEFAULT_COOLIFY_API_DOCKERFILE = '/Dockerfile.coolify-api'; private const DEFAULT_COOLIFY_API_DOCKERFILE = '/Dockerfile.coolify-api';
private const RELEASE_GATE_ALLOWED_FETCH_HOST_SUFFIXES = ['truckwash.io'];
private const RELEASE_GATE_MAX_PATHS = 10;
private const RELEASE_GATE_MAX_ASSETS = 50;
private const RELEASE_API_RUNTIME_ENV_KEYS = [ private const RELEASE_API_RUNTIME_ENV_KEYS = [
'USE_ENV', 'USE_ENV',
'DEBUG', 'DEBUG',
@@ -1018,6 +1021,7 @@ class release_manager
'channel_slug' => $channelSlug, 'channel_slug' => $channelSlug,
'route_slug' => $routeSlug, 'route_slug' => $routeSlug,
'app' => $app, 'app' => $app,
'apps' => $this->releaseTestAppsFromInput($input),
'repository' => $repository, 'repository' => $repository,
'branch' => $branch, 'branch' => $branch,
'auto_sync' => $this->toBool($input['auto_sync'] ?? false), 'auto_sync' => $this->toBool($input['auto_sync'] ?? false),
@@ -1031,9 +1035,10 @@ class release_manager
'api_ping_paths' => $this->releaseGateStringArray( 'api_ping_paths' => $this->releaseGateStringArray(
$input['api_ping_paths'] $input['api_ping_paths']
?? $input['api_paths'] ?? $input['api_paths']
?? ['/master/api/ping'] ?? ['/master/api/ping'],
self::RELEASE_GATE_MAX_PATHS
), ),
'shell_paths' => $this->releaseGateStringArray($input['shell_paths'] ?? ['/', '/guest/book/wash']), 'shell_paths' => $this->releaseGateStringArray($input['shell_paths'] ?? ['/', '/guest/book/wash'], self::RELEASE_GATE_MAX_PATHS),
]; ];
} }
@@ -1064,6 +1069,7 @@ class release_manager
private function releaseGateAutoSyncValidationSteps(array $gateInput, ?array $channel): array private function releaseGateAutoSyncValidationSteps(array $gateInput, ?array $channel): array
{ {
$steps = []; $steps = [];
$requiredChecks = is_array($gateInput['required_checks'] ?? null) ? $gateInput['required_checks'] : [];
$context = [ $context = [
'channel_slug' => $gateInput['channel_slug'] ?? null, 'channel_slug' => $gateInput['channel_slug'] ?? null,
'app' => $gateInput['app'] ?? null, 'app' => $gateInput['app'] ?? null,
@@ -1071,6 +1077,7 @@ class release_manager
'branch' => $gateInput['branch'] ?? null, 'branch' => $gateInput['branch'] ?? null,
'expected_commit' => $gateInput['expected_commit'] ?? null, 'expected_commit' => $gateInput['expected_commit'] ?? null,
'workflow_url' => $gateInput['workflow_url'] ?? null, 'workflow_url' => $gateInput['workflow_url'] ?? null,
'required_checks' => $requiredChecks,
]; ];
if ($channel === null) { if ($channel === null) {
@@ -1106,6 +1113,17 @@ class release_manager
'context' => $context, 'context' => $context,
]; ];
} }
if ($requiredChecks === []) {
$steps[] = [
'step_key' => 'auto_sync_required_checks',
'label' => 'Automatic update required checks',
'status' => 'failed',
'message' => 'Automatic container updates require at least one release gate check.',
'diagnostic' => 'required_checks was empty.',
'solution_hint' => 'Include required_checks (for example static_artifact and/or api_gateway) in the release gate payload.',
'context' => $context,
];
}
if ($steps === []) { if ($steps === []) {
$steps[] = [ $steps[] = [
@@ -1120,7 +1138,7 @@ class release_manager
return $steps; return $steps;
} }
private function releaseGateStringArray(mixed $value): array private function releaseGateStringArray(mixed $value, int $limit = 50): array
{ {
if (is_string($value)) { if (is_string($value)) {
$value = preg_split('/\s*,\s*/', trim($value)) ?: []; $value = preg_split('/\s*,\s*/', trim($value)) ?: [];
@@ -1136,7 +1154,7 @@ class release_manager
$values[] = $item; $values[] = $item;
} }
} }
return $values; return array_slice($values, 0, max(0, $limit));
} }
private function normalizeReleaseGateUrl(string $value): string private function normalizeReleaseGateUrl(string $value): string
@@ -1376,14 +1394,14 @@ class release_manager
} }
} }
$assetUrls = $this->releaseGateUniqueStrings(array_merge( $assetUrls = array_slice($this->releaseGateUniqueStrings(array_merge(
['release-manifest.json', 'release-entry.json'], ['release-manifest.json', 'release-entry.json'],
[(string)($manifestData['entry'] ?? '')], [(string)($manifestData['entry'] ?? '')],
is_array($manifestData['css'] ?? null) ? $manifestData['css'] : [], is_array($manifestData['css'] ?? null) ? $manifestData['css'] : [],
is_array($manifestData['index_asset_urls'] ?? null) ? $manifestData['index_asset_urls'] : [], is_array($manifestData['index_asset_urls'] ?? null) ? $manifestData['index_asset_urls'] : [],
is_array($manifestData['pwa_asset_urls'] ?? null) ? $manifestData['pwa_asset_urls'] : [], is_array($manifestData['pwa_asset_urls'] ?? null) ? $manifestData['pwa_asset_urls'] : [],
is_array($manifestData['asset_urls'] ?? null) ? $manifestData['asset_urls'] : [] is_array($manifestData['asset_urls'] ?? null) ? $manifestData['asset_urls'] : []
)); )), 0, self::RELEASE_GATE_MAX_ASSETS);
$verifiedAssets = 0; $verifiedAssets = 0;
foreach ($assetUrls as $assetUrl) { foreach ($assetUrls as $assetUrl) {
if ($assetUrl === '/index.html') { if ($assetUrl === '/index.html') {
@@ -1416,6 +1434,8 @@ class release_manager
]; ];
} }
$expectedCommit = self::normalizeCommitSha((string)($gateInput['expected_commit'] ?? ''));
$enforceExpectedCommit = $expectedCommit !== '' && !$this->releaseGateAutoSyncRequested($gateInput);
$checked = []; $checked = [];
try { try {
foreach ($gateInput['api_ping_paths'] as $path) { foreach ($gateInput['api_ping_paths'] as $path) {
@@ -1424,9 +1444,19 @@ class release_manager
if (array_key_exists('success', $payload) && $payload['success'] !== true) { if (array_key_exists('success', $payload) && $payload['success'] !== true) {
throw new RuntimeException(sprintf('%s returned success=false.', $path)); throw new RuntimeException(sprintf('%s returned success=false.', $path));
} }
$actualCommit = $this->releaseGateApiPayloadCommitSha($payload);
if ($enforceExpectedCommit && !$this->releaseGateCommitMatches($actualCommit, $expectedCommit)) {
throw new RuntimeException(sprintf(
'%s returned commit %s, expected %s.',
$path,
$actualCommit !== '' ? $actualCommit : 'unknown',
$expectedCommit
));
}
$checked[] = [ $checked[] = [
'path' => $path, 'path' => $path,
'status' => $json['status'], 'status' => $json['status'],
'commit_sha' => $actualCommit !== '' ? $actualCommit : null,
]; ];
} }
} catch (Throwable $throwable) { } catch (Throwable $throwable) {
@@ -1453,10 +1483,24 @@ class release_manager
'context' => [ 'context' => [
'api_base_url' => $apiBaseUrl, 'api_base_url' => $apiBaseUrl,
'checked' => $checked, 'checked' => $checked,
'expected_commit' => $expectedCommit !== '' ? $expectedCommit : null,
], ],
]; ];
} }
private function releaseGateApiPayloadCommitSha(array $payload): string
{
$data = is_array($payload['data'] ?? null) ? $payload['data'] : $payload;
foreach (['api_commit_sha', 'backend_version', 'commit_sha', 'version'] as $key) {
$commit = self::normalizeCommitSha((string)($data[$key] ?? ''));
if ($commit !== '') {
return $commit;
}
}
return '';
}
private function releaseGateFetchJson(string $baseUrl, string $path): array private function releaseGateFetchJson(string $baseUrl, string $path): array
{ {
$result = $this->releaseGateFetch($this->releaseGateJoinUrl($baseUrl, $path)); $result = $this->releaseGateFetch($this->releaseGateJoinUrl($baseUrl, $path));
@@ -1502,15 +1546,18 @@ class release_manager
private function releaseGateFetch(string $url): array private function releaseGateFetch(string $url): array
{ {
$this->assertReleaseGateFetchUrlAllowed($url);
$curl = curl_init($url); $curl = curl_init($url);
if ($curl === false) { if ($curl === false) {
throw new RuntimeException('Could not initialize release gate request.'); throw new RuntimeException('Could not initialize release gate request.');
} }
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true); curl_setopt($curl, CURLOPT_FOLLOWLOCATION, false);
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 5); curl_setopt($curl, CURLOPT_MAXREDIRS, 0);
curl_setopt($curl, CURLOPT_TIMEOUT, 15); curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 3);
curl_setopt($curl, CURLOPT_TIMEOUT, 8);
curl_setopt($curl, CURLOPT_NOSIGNAL, true); curl_setopt($curl, CURLOPT_NOSIGNAL, true);
curl_setopt($curl, CURLOPT_HTTPHEADER, [ curl_setopt($curl, CURLOPT_HTTPHEADER, [
'Accept: application/json, text/html, */*', 'Accept: application/json, text/html, */*',
@@ -1539,13 +1586,82 @@ class release_manager
private function releaseGateJoinUrl(string $baseUrl, string $path): string private function releaseGateJoinUrl(string $baseUrl, string $path): string
{ {
if (preg_match('#^https?://#i', $path) === 1) { $path = trim($path);
return $path; $parts = parse_url($path);
if (is_array($parts) && (!empty($parts['scheme']) || !empty($parts['host']))) {
throw new RuntimeException('Release gate paths must be relative to the configured Truckwash release host.');
}
if (str_starts_with($path, '//')) {
throw new RuntimeException('Release gate paths must not be protocol-relative URLs.');
} }
return rtrim($baseUrl, '/') . '/' . ltrim($path, '/'); return rtrim($baseUrl, '/') . '/' . ltrim($path, '/');
} }
private function assertReleaseGateFetchUrlAllowed(string $url): void
{
$parts = parse_url($url);
$scheme = strtolower((string)($parts['scheme'] ?? ''));
$host = strtolower(rtrim((string)($parts['host'] ?? ''), '.'));
if (!in_array($scheme, ['http', 'https'], true) || $host === '') {
throw new RuntimeException('Release gate checks may only fetch HTTP(S) URLs from Truckwash release hosts.');
}
if (!$this->releaseGateFetchHostAllowed($host)) {
throw new RuntimeException('Release gate checks may only fetch configured Truckwash release hosts.');
}
$addresses = $this->releaseGateResolveHost($host);
if ($addresses === []) {
throw new RuntimeException('Release gate host could not be resolved.');
}
foreach ($addresses as $address) {
if (!$this->releaseGatePublicIpAllowed($address)) {
throw new RuntimeException('Release gate host resolved to a private, loopback, or reserved address.');
}
}
}
private function releaseGateFetchHostAllowed(string $host): bool
{
$host = strtolower(rtrim($host, '.'));
foreach (self::RELEASE_GATE_ALLOWED_FETCH_HOST_SUFFIXES as $allowedSuffix) {
$allowedSuffix = strtolower($allowedSuffix);
if ($host === $allowedSuffix || str_ends_with($host, '.' . $allowedSuffix)) {
return true;
}
}
return false;
}
private function releaseGateResolveHost(string $host): array
{
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
return [$host];
}
$addresses = gethostbynamel($host) ?: [];
if (function_exists('dns_get_record')) {
foreach (dns_get_record($host, DNS_AAAA) ?: [] as $record) {
if (is_array($record) && !empty($record['ipv6'])) {
$addresses[] = (string)$record['ipv6'];
}
}
}
return array_values(array_unique(array_filter($addresses, fn(string $address): bool => filter_var($address, FILTER_VALIDATE_IP) !== false)));
}
private function releaseGatePublicIpAllowed(string $address): bool
{
return filter_var(
$address,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
) !== false;
}
private function releaseGateCommitMatches(string $actual, string $expected): bool private function releaseGateCommitMatches(string $actual, string $expected): bool
{ {
$expected = strtolower(trim($expected)); $expected = strtolower(trim($expected));
@@ -1976,11 +2092,7 @@ class release_manager
$eventId = (int)$event['id']; $eventId = (int)$event['id'];
if (!$this->acquireReleaseAutoSyncLock($eventId)) { if (!$this->acquireReleaseAutoSyncLock($eventId)) {
return [ return $this->waitForReleaseAutoSyncEventResult($eventId, $channelId, $app, $commitSha, $gateInput);
'step_status' => 'passed',
'message' => 'Automatic container update is already being processed for this commit.',
'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
];
} }
try { try {
@@ -2062,6 +2174,67 @@ class release_manager
} }
} }
private function waitForReleaseAutoSyncEventResult(int $eventId, int $channelId, string $app, string $commitSha, array $gateInput): array
{
$timeout = max(0, min(300, (int)($gateInput['wait_timeout_seconds'] ?? 300)));
$pollInterval = max(1, min(60, (int)($gateInput['poll_interval_seconds'] ?? 10)));
$deadline = time() + $timeout;
$attempts = 0;
$lastStatus = 'unknown';
do {
$attempts++;
$event = $this->releaseAutoSyncEventById($eventId);
if ($event === null) {
throw new RuntimeException('Automatic container update disappeared while another request was processing it.');
}
$lastStatus = (string)($event['status'] ?? 'unknown');
if (in_array($lastStatus, ['promoted', 'deployed'], true)) {
$deployment = null;
$deploymentId = $this->nullablePositiveInt($event['deployment_id'] ?? null);
if ($deploymentId !== null) {
$deployment = $this->getDeployment($deploymentId);
}
$deployment ??= $this->currentDeploymentForChannelApp($channelId, $app);
if ($deployment !== null && $this->releaseGateCommitMatches((string)($deployment['commit_sha'] ?? ''), $commitSha)) {
return [
'step_status' => 'passed',
'message' => sprintf('%s container update completed by an in-flight request at %s.', strtoupper($app), substr($commitSha, 0, 12)),
'deployment' => $this->publicDeployment($deployment),
'auto_sync_event' => $this->publicReleaseAutoSyncEvent($event),
'attempts' => $attempts,
];
}
throw new RuntimeException(sprintf(
'Automatic container update completed for event %d but the active %s deployment does not match %s.',
$eventId,
strtoupper($app),
$commitSha
));
}
if ($lastStatus === 'failed') {
$message = trim((string)($event['error_message'] ?? 'Automatic container update failed in another request.'));
throw new RuntimeException($message !== '' ? $message : 'Automatic container update failed in another request.');
}
if (time() >= $deadline) {
break;
}
sleep($pollInterval);
} while (true);
throw new RuntimeException(sprintf(
'Automatic container update is already being processed for event %d but did not finish within %d seconds; last status was %s.',
$eventId,
$timeout,
$lastStatus
));
}
private function upsertReleaseAutoSyncEvent(array $input): array private function upsertReleaseAutoSyncEvent(array $input): array
{ {
$channelId = (int)$input['channel_id']; $channelId = (int)$input['channel_id'];
@@ -4713,22 +4886,22 @@ class release_manager
throw new RuntimeException('Beta release channel uses production services and does not promote separate release bundles.'); throw new RuntimeException('Beta release channel uses production services and does not promote separate release bundles.');
} }
$this->assertBetaProductionDataPolicy($channel, $serviceSet); $this->assertBetaProductionDataPolicy($channel, $serviceSet);
$frontendVersionId = $this->nullablePositiveInt($bundle['frontend_version_id'] ?? null);
$apiVersionId = $this->nullablePositiveInt($bundle['api_version_id'] ?? null);
$this->assertReleaseGatePassedForPromotion( $this->assertReleaseGatePassedForPromotion(
$channelId, $channelId,
(string)($bundle['frontend_commit_sha'] ?? ''), (string)($bundle['frontend_commit_sha'] ?? ''),
null, null,
'frontend' 'frontend'
); );
if (trim((string)($bundle['api_commit_sha'] ?? '')) !== '') { if ($apiVersionId !== null) {
$this->assertReleaseGatePassedForPromotion( $this->assertReleaseGatePassedForPromotion(
$channelId, $channelId,
(string)$bundle['api_commit_sha'], (string)($bundle['api_commit_sha'] ?? ''),
null, null,
'api' 'api'
); );
} }
$frontendVersionId = $this->nullablePositiveInt($bundle['frontend_version_id'] ?? null);
$apiVersionId = $this->nullablePositiveInt($bundle['api_version_id'] ?? null);
$deploymentId = $this->nullablePositiveInt($bundle['api_deployment_id'] ?? null) $deploymentId = $this->nullablePositiveInt($bundle['api_deployment_id'] ?? null)
?? $this->nullablePositiveInt($bundle['frontend_deployment_id'] ?? null); ?? $this->nullablePositiveInt($bundle['frontend_deployment_id'] ?? null);
@@ -6142,6 +6315,7 @@ class release_manager
} }
$deployment = $client->deployResource($serviceUuid, $this->releaseCoolifyForceRebuild($context)); $deployment = $client->deployResource($serviceUuid, $this->releaseCoolifyForceRebuild($context));
$previousApplications = $this->stopCoolifyPreviousApplications($client, $context, $serviceUuid);
return [ return [
'service_uuid' => $serviceUuid, 'service_uuid' => $serviceUuid,
'resource_type' => $resourceType, 'resource_type' => $resourceType,
@@ -6151,9 +6325,76 @@ class release_manager
'updated' => self::redactPayload($update ?? []), 'updated' => self::redactPayload($update ?? []),
'runtime_env' => $runtimeEnvUpdate, 'runtime_env' => $runtimeEnvUpdate,
'deployment' => self::redactPayload($deployment), 'deployment' => self::redactPayload($deployment),
'previous_applications' => self::redactPayload($previousApplications),
]; ];
} }
private function stopCoolifyPreviousApplications(coolify_api_client $client, array $context, string $activeUuid): array
{
$stopped = [];
foreach ($this->releaseCoolifyPreviousApplicationUuids($context, $activeUuid) as $uuid) {
try {
$stopped[] = [
'uuid' => $uuid,
'status' => 'stop_requested',
'result' => $client->stopApplication($uuid),
];
} catch (Throwable $throwable) {
$stopped[] = [
'uuid' => $uuid,
'status' => 'warning',
'error' => $throwable->getMessage(),
];
}
}
return $stopped;
}
private function releaseCoolifyPreviousApplicationUuids(array $context, string $activeUuid): array
{
$values = [];
foreach ([
'coolify_previous_application_uuid',
'coolify_previous_artifact_app_uuid',
'coolify_previous_artifact_application_uuid',
'previous_application_uuid',
'previous_app_uuid',
] as $key) {
if (is_scalar($context[$key] ?? null)) {
$values[] = (string)$context[$key];
}
}
foreach ([
'coolify_previous_application_uuids',
'coolify_previous_artifact_app_uuids',
'previous_application_uuids',
'previous_app_uuids',
] as $key) {
if (!is_array($context[$key] ?? null)) {
continue;
}
foreach ($context[$key] as $value) {
if (is_scalar($value)) {
$values[] = (string)$value;
}
}
}
$activeUuid = trim($activeUuid);
$uuids = [];
foreach ($values as $value) {
$uuid = trim((string)$value);
if ($uuid === '' || $uuid === $activeUuid || in_array($uuid, $uuids, true)) {
continue;
}
$uuids[] = $uuid;
}
return $uuids;
}
private function updateCoolifyReleaseRuntimeEnv(coolify_api_client $client, string $resourceUuid, string $resourceType, array $target, array $context): ?array private function updateCoolifyReleaseRuntimeEnv(coolify_api_client $client, string $resourceUuid, string $resourceType, array $target, array $context): ?array
{ {
$env = $this->releaseCoolifyRuntimeEnv($target, $context); $env = $this->releaseCoolifyRuntimeEnv($target, $context);
@@ -6162,6 +6403,7 @@ class release_manager
} }
if ($resourceType === 'application') { if ($resourceType === 'application') {
$this->deleteCoolifyGeneratedCommitEnvs($client, $resourceUuid, $target, $context);
$client->updateApplicationEnvsBulk($resourceUuid, $env); $client->updateApplicationEnvsBulk($resourceUuid, $env);
} else { } else {
$client->updateServiceEnvsBulk($resourceUuid, $env); $client->updateServiceEnvsBulk($resourceUuid, $env);
@@ -6174,12 +6416,45 @@ class release_manager
]; ];
} }
private function deleteCoolifyGeneratedCommitEnvs(coolify_api_client $client, string $resourceUuid, array $target, array $context): void
{
$keys = $this->releaseCoolifyGeneratedCommitEnvKeys($target, $context);
if ($keys === []) {
return;
}
try {
$rows = $this->payloadRows($client->listApplicationEnvs($resourceUuid));
} catch (Throwable) {
return;
}
foreach ($rows as $row) {
if (!is_array($row)) {
continue;
}
$key = trim((string)($row['key'] ?? $row['name'] ?? ''));
$uuid = trim((string)($row['uuid'] ?? $row['id'] ?? ''));
if ($key === '' || $uuid === '' || !in_array($key, $keys, true)) {
continue;
}
try {
$client->deleteApplicationEnv($resourceUuid, $uuid);
} catch (Throwable) {
}
}
}
private function releaseCoolifyRuntimeEnv(array $target, array $context): array private function releaseCoolifyRuntimeEnv(array $target, array $context): array
{ {
$contextEnv = $this->releaseCoolifyContextEnv($context); $contextEnv = $this->releaseCoolifyContextEnv($context);
$env = $contextEnv; $env = $contextEnv;
$app = strtolower(trim((string)($target['app'] ?? ''))); $app = strtolower(trim((string)($target['app'] ?? '')));
$deploymentCommitSha = self::normalizeCommitSha($this->releaseCoolifyGitCommitSha($target, $context));
if ($app !== 'api') { if ($app !== 'api') {
$this->applyReleaseCoolifyCommitRuntimeEnv($env, $app, $deploymentCommitSha);
return $env; return $env;
} }
@@ -6201,21 +6476,37 @@ class release_manager
$this->appendRuntimeEnvValue($env, $key, $value); $this->appendRuntimeEnvValue($env, $key, $value);
} }
$deploymentCommitSha = self::normalizeCommitSha($this->releaseCoolifyGitCommitSha($target, $context));
if ($deploymentCommitSha !== '') {
foreach (['API_COMMIT_SHA', 'COMMIT_SHA'] as $key) {
if (!array_key_exists($key, $contextEnv)) {
$env[$key] = $deploymentCommitSha;
}
}
}
$env = array_replace($env, $contextEnv); $env = array_replace($env, $contextEnv);
$env['USE_ENV'] = trim((string)($env['USE_ENV'] ?? '')) !== '' ? $env['USE_ENV'] : 'true'; $env['USE_ENV'] = trim((string)($env['USE_ENV'] ?? '')) !== '' ? $env['USE_ENV'] : 'true';
$env['CORS'] = cors_policy::withRequiredOrigins((string)($env['CORS'] ?? '')); $env['CORS'] = cors_policy::withRequiredOrigins((string)($env['CORS'] ?? ''));
$this->applyReleaseCoolifyCommitRuntimeEnv($env, $app, $deploymentCommitSha);
return $this->normalizeCoolifyRuntimeEnv($env); return $this->normalizeCoolifyRuntimeEnv($env);
} }
private function applyReleaseCoolifyCommitRuntimeEnv(array &$env, string $app, string $deploymentCommitSha): void
{
if ($deploymentCommitSha === '') {
return;
}
foreach ($this->releaseCoolifyGeneratedCommitEnvKeys(['app' => $app], []) as $key) {
$env[$key] = $deploymentCommitSha;
}
}
private function releaseCoolifyGeneratedCommitEnvKeys(array $target, array $context): array
{
$app = strtolower(trim((string)($target['app'] ?? $context['app'] ?? '')));
if ($app === 'frontend') {
return ['SOURCE_COMMIT', 'RELEASE_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'VITE_COMMIT_HASH'];
}
if ($app === 'api') {
return ['API_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'RELEASE_COMMIT_SHA'];
}
return [];
}
private function releaseCoolifyContextEnv(array $context): array private function releaseCoolifyContextEnv(array $context): array
{ {
$env = []; $env = [];
@@ -6923,6 +7214,13 @@ class release_manager
private function releaseCoolifyGitCommitSha(array $target, array $context): string private function releaseCoolifyGitCommitSha(array $target, array $context): string
{ {
foreach (['commit_sha', 'commit'] as $key) {
$value = trim((string)($target[$key] ?? ''));
if ($value !== '') {
return $value;
}
}
foreach ([ foreach ([
'coolify_git_commit_sha', 'coolify_git_commit_sha',
'git_commit_sha', 'git_commit_sha',
@@ -8838,7 +9136,7 @@ class release_manager
if (array_keys($payload) === range(0, count($payload) - 1)) { if (array_keys($payload) === range(0, count($payload) - 1)) {
return $payload; return $payload;
} }
foreach (['data', 'services', 'projects', 'servers', 'github_apps', 'results'] as $key) { foreach (['data', 'services', 'projects', 'servers', 'github_apps', 'envs', 'environment_variables', 'results'] as $key) {
if (is_array($payload[$key] ?? null)) { if (is_array($payload[$key] ?? null)) {
return $this->payloadRows($payload[$key]); return $this->payloadRows($payload[$key]);
} }
@@ -9343,7 +9641,7 @@ class release_manager
'status' => 'draft', 'status' => 'draft',
'metadata' => [ 'metadata' => [
'commit_mode' => $input['commit_mode'], 'commit_mode' => $input['commit_mode'],
'github_access' => $input['github_access'], 'github_access' => self::releaseVersionGithubAccessMetadata($input['github_access'] ?? null),
'bundle_member' => true, 'bundle_member' => true,
], ],
]); ]);
@@ -9676,7 +9974,7 @@ class release_manager
return null; return null;
} }
$metadata = self::jsonDecode($version['metadata_json'] ?? null); $metadata = self::publicReleaseVersionMetadata(self::jsonDecode($version['metadata_json'] ?? null));
$commit = $this->versionGithubCommit(['metadata' => $metadata]); $commit = $this->versionGithubCommit(['metadata' => $metadata]);
return [ return [
@@ -9699,6 +9997,26 @@ class release_manager
]; ];
} }
private static function publicReleaseVersionMetadata(mixed $metadata): array
{
if (!is_array($metadata)) {
return [];
}
unset($metadata['github_access']);
return $metadata;
}
private static function releaseVersionGithubAccessMetadata(mixed $githubAccess): ?array
{
if (!is_array($githubAccess)) {
return null;
}
unset($githubAccess['commit'], $githubAccess['latest_commit'], $githubAccess['commit_authored_at']);
return $githubAccess;
}
private function publicAssignment(array $assignment): array private function publicAssignment(array $assignment): array
{ {
return [ return [
@@ -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,
@@ -197,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',
@@ -239,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;
} }
/** /**
+100 -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,90 @@ 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.',
];
}
protected function get_customer_registration_webhook_url(): string
{
return trim((string)$this->getConfig()->customer_registration_webhook_url->getVariableValue());
}
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.";
}
}
@@ -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';
@@ -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);
} }
} }
@@ -3426,7 +3426,7 @@ BASH;
} }
try { try {
$this->shellyRelayOptionsCache = (new shelly_relay_inventory())->listRelayOptions(); $this->shellyRelayOptionsCache = (new shelly_relay_inventory())->listRelayOptions(true);
} catch (\Throwable) { } catch (\Throwable) {
$this->shellyRelayOptionsCache = []; $this->shellyRelayOptionsCache = [];
} }
@@ -4021,7 +4021,7 @@ BASH;
{ {
$configured = $this->configuredBrokerSharedSecret(); $configured = $this->configuredBrokerSharedSecret();
if ($configured === '') { if ($configured === '') {
return true; return false;
} }
return $secret !== null && hash_equals($configured, trim($secret)); return $secret !== null && hash_equals($configured, trim($secret));
@@ -4036,12 +4036,8 @@ BASH;
$target = strtolower(trim((string)($options['target'] ?? 'all'))); $target = strtolower(trim((string)($options['target'] ?? 'all')));
$target = in_array($target, ['internal', 'public', 'secret', 'all'], true) ? $target : 'all'; $target = in_array($target, ['internal', 'public', 'secret', 'all'], true) ? $target : 'all';
$internalUrl = $this->normalizeBrokerDiagnosticBaseUrl( $internalUrl = $this->normalizeBrokerDiagnosticBaseUrl($this->configuredBrokerInternalUrl());
array_key_exists('broker_url', $options) ? $options['broker_url'] : $this->configuredBrokerInternalUrl() $publicConfigured = $this->configuredPublicBrokerUrl();
);
$publicConfigured = array_key_exists('public_broker_url', $options)
? trim((string)$options['public_broker_url'])
: $this->configuredPublicBrokerUrl();
$publicUrl = $this->normalizeBrokerDiagnosticBaseUrl( $publicUrl = $this->normalizeBrokerDiagnosticBaseUrl(
$publicConfigured !== '' ? $publicConfigured : $this->deriveBrokerPublicUrl() $publicConfigured !== '' ? $publicConfigured : $this->deriveBrokerPublicUrl()
); );
@@ -4133,7 +4129,7 @@ BASH;
$health = $this->brokerHttpProbe($baseUrl['url'] . '/api/health'); $health = $this->brokerHttpProbe($baseUrl['url'] . '/api/health');
if (($health['status_code'] ?? null) === 200 && !empty($health['json']['ok'])) { if (($health['status_code'] ?? null) === 200 && !empty($health['json']['ok'])) {
return array_merge($health, [ return array_merge($this->redactBrokerDiagnosticProbe($health), [
'ok' => true, 'ok' => true,
'status' => 'connected', 'status' => 'connected',
'url' => $baseUrl['url'], 'url' => $baseUrl['url'],
@@ -4142,7 +4138,7 @@ BASH;
} }
if (($health['status_code'] ?? null) === 404 && $this->isBrokerNotFoundProbe($health)) { if (($health['status_code'] ?? null) === 404 && $this->isBrokerNotFoundProbe($health)) {
return array_merge($health, [ return array_merge($this->redactBrokerDiagnosticProbe($health), [
'ok' => true, 'ok' => true,
'status' => 'connected_legacy', 'status' => 'connected_legacy',
'url' => $baseUrl['url'], 'url' => $baseUrl['url'],
@@ -4151,7 +4147,7 @@ BASH;
} }
if (($health['status_code'] ?? null) !== null) { if (($health['status_code'] ?? null) !== null) {
return array_merge($health, [ return array_merge($this->redactBrokerDiagnosticProbe($health), [
'ok' => false, 'ok' => false,
'status' => 'unexpected_response', 'status' => 'unexpected_response',
'url' => $baseUrl['url'], 'url' => $baseUrl['url'],
@@ -4159,7 +4155,7 @@ BASH;
]); ]);
} }
return array_merge($health, [ return array_merge($this->redactBrokerDiagnosticProbe($health), [
'ok' => false, 'ok' => false,
'status' => 'unreachable', 'status' => 'unreachable',
'url' => $baseUrl['url'], 'url' => $baseUrl['url'],
@@ -4187,7 +4183,7 @@ BASH;
if (($diagnostic['status_code'] ?? null) === 200 && !empty($diagnostic['json']['ok'])) { if (($diagnostic['status_code'] ?? null) === 200 && !empty($diagnostic['json']['ok'])) {
$required = (bool)($diagnostic['json']['shared_secret_required'] ?? false); $required = (bool)($diagnostic['json']['shared_secret_required'] ?? false);
return array_merge($diagnostic, [ return array_merge($this->redactBrokerDiagnosticProbe($diagnostic), [
'ok' => true, 'ok' => true,
'status' => $required ? 'validated' : 'not_required', 'status' => $required ? 'validated' : 'not_required',
'url' => $baseUrl['url'], 'url' => $baseUrl['url'],
@@ -4198,7 +4194,7 @@ BASH;
} }
if (($diagnostic['status_code'] ?? null) === 403) { if (($diagnostic['status_code'] ?? null) === 403) {
return array_merge($diagnostic, [ return array_merge($this->redactBrokerDiagnosticProbe($diagnostic), [
'ok' => false, 'ok' => false,
'status' => 'secret_rejected', 'status' => 'secret_rejected',
'url' => $baseUrl['url'], 'url' => $baseUrl['url'],
@@ -4211,7 +4207,7 @@ BASH;
} }
if (($diagnostic['status_code'] ?? null) !== null) { if (($diagnostic['status_code'] ?? null) !== null) {
return array_merge($diagnostic, [ return array_merge($this->redactBrokerDiagnosticProbe($diagnostic), [
'ok' => false, 'ok' => false,
'status' => 'unexpected_response', 'status' => 'unexpected_response',
'url' => $baseUrl['url'], 'url' => $baseUrl['url'],
@@ -4219,7 +4215,7 @@ BASH;
]); ]);
} }
return array_merge($diagnostic, [ return array_merge($this->redactBrokerDiagnosticProbe($diagnostic), [
'ok' => false, 'ok' => false,
'status' => 'unreachable', 'status' => 'unreachable',
'url' => $baseUrl['url'], 'url' => $baseUrl['url'],
@@ -4242,7 +4238,7 @@ BASH;
); );
if (($legacy['status_code'] ?? null) === 200 && !empty($legacy['json']['ok'])) { if (($legacy['status_code'] ?? null) === 200 && !empty($legacy['json']['ok'])) {
return array_merge($legacy, [ return array_merge($this->redactBrokerDiagnosticProbe($legacy), [
'ok' => true, 'ok' => true,
'status' => 'validated_legacy', 'status' => 'validated_legacy',
'url' => $baseUrl['url'], 'url' => $baseUrl['url'],
@@ -4251,7 +4247,7 @@ BASH;
} }
if (($legacy['status_code'] ?? null) === 403) { if (($legacy['status_code'] ?? null) === 403) {
return array_merge($legacy, [ return array_merge($this->redactBrokerDiagnosticProbe($legacy), [
'ok' => false, 'ok' => false,
'status' => 'secret_rejected', 'status' => 'secret_rejected',
'url' => $baseUrl['url'], 'url' => $baseUrl['url'],
@@ -4259,7 +4255,7 @@ BASH;
]); ]);
} }
return array_merge($legacy, [ return array_merge($this->redactBrokerDiagnosticProbe($legacy), [
'ok' => false, 'ok' => false,
'status' => ($legacy['status_code'] ?? null) === null ? 'unreachable' : 'unexpected_response', 'status' => ($legacy['status_code'] ?? null) === null ? 'unreachable' : 'unexpected_response',
'url' => $baseUrl['url'], 'url' => $baseUrl['url'],
@@ -4267,6 +4263,17 @@ BASH;
]); ]);
} }
/**
* @param array<string,mixed> $probe
* @return array<string,mixed>
*/
private function redactBrokerDiagnosticProbe(array $probe): array
{
unset($probe['json']);
$probe['body_excerpt'] = null;
return $probe;
}
/** /**
* @param array{url:?string,error:?string} $baseUrl * @param array{url:?string,error:?string} $baseUrl
* @return array<string,mixed> * @return array<string,mixed>
@@ -4345,7 +4352,7 @@ BASH;
'elapsed_ms' => $elapsedMs, 'elapsed_ms' => $elapsedMs,
'error' => null, 'error' => null,
'json' => is_array($decoded) ? $decoded : null, 'json' => is_array($decoded) ? $decoded : null,
'body_excerpt' => self::trimInstallSessionText($body, 512), 'body_excerpt' => null,
]; ];
} }
@@ -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));
} }
} }
@@ -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);
} }
} }
@@ -7,6 +7,7 @@ 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 ?int $subuser_id = null;
public ?string $wash_mode = null;
public bool $defer_relay_side_effects = false; public bool $defer_relay_side_effects = false;
/** /**
@@ -32,6 +33,22 @@ class selfserve_lane_command_arguments
return $this; 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;
@@ -50,6 +67,12 @@ class selfserve_lane_command_arguments
if (array_key_exists('subuser_id', $params)) { if (array_key_exists('subuser_id', $params)) {
$this->setSubuserId($params['subuser_id'] === null ? null : (int)$params['subuser_id']); $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'],
@@ -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;
@@ -46,6 +46,11 @@ use objects\selfserve_config_versions_o;
class selfserve_studio_graph class selfserve_studio_graph
{ {
private const DEFAULT_PATH_MAX_STATES = 2048;
private const MAX_PATH_MAX_STATES = 2048;
private const DEFAULT_PATH_SAMPLE_LIMIT = 2048;
private const MAX_PATH_SAMPLE_LIMIT = 2048;
/** @var array<string,array<int,string>> */ /** @var array<string,array<int,string>> */
private array $columnCache = []; private array $columnCache = [];
@@ -342,7 +347,7 @@ class selfserve_studio_graph
if ($versioning->isV2Config($config)) { if ($versioning->isV2Config($config)) {
foreach ($operations as $operation) { foreach ($operations as $operation) {
if (is_array($operation)) { if (is_array($operation)) {
$this->applyConfigOperation($departmentId, $config, $operation); $this->applyConfigOperation($departmentId, $config, $operation, $permissions);
} }
} }
@@ -356,7 +361,7 @@ class selfserve_studio_graph
} else { } else {
foreach ($operations as $operation) { foreach ($operations as $operation) {
if (is_array($operation)) { if (is_array($operation)) {
$this->applyOperation($departmentId, $operation); $this->applyOperation($departmentId, $operation, $permissions);
} }
} }
} }
@@ -512,21 +517,11 @@ class selfserve_studio_graph
throw new \RuntimeException('lane_id is required for studio simulation.'); throw new \RuntimeException('lane_id is required for studio simulation.');
} }
$configSource = strtolower(trim((string)($payload['config_source'] ?? 'draft')));
if (!in_array($configSource, ['draft', 'published'], true)) {
$configSource = 'draft';
}
$versioning = new selfserve_config_versioning(); $versioning = new selfserve_config_versioning();
if ($configSource === 'published') { $configContext = $this->loadSimulationConfig($departmentId, $payload, $permissions, $versioning);
$version = $versioning->getPublishedV2Config($departmentId); $configSource = $configContext['config_source'];
$config = is_array($version['config'] ?? null) ? (array)$version['config'] : null; $config = $configContext['config'];
$versionId = isset($version['version_id']) ? (int)$version['version_id'] : null; $versionId = $configContext['version_id'];
} else {
$version = $versioning->ensureDraftFromLegacy($departmentId, $userId, false);
$config = is_array($version['config'] ?? null) ? (array)$version['config'] : $versioning->snapshotLegacyConfig($departmentId);
$versionId = isset($version['id']) ? (int)$version['id'] : null;
}
$includeHardware = filter_var($payload['include_hardware'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); $includeHardware = filter_var($payload['include_hardware'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
$includeHardware = $includeHardware !== false; $includeHardware = $includeHardware !== false;
@@ -587,6 +582,70 @@ class selfserve_studio_graph
); );
} }
/**
* @param array<string,mixed> $payload
* @param array<string,bool> $permissions
* @return array{config_source:string,config:array<string,mixed>|null,version_id:int|null}
*/
private function loadSimulationConfig(
int $departmentId,
array $payload,
array $permissions,
selfserve_config_versioning $versioning
): array {
$configSource = $this->resolveSimulationConfigSource($payload, $permissions);
if ($configSource === 'published') {
$version = $versioning->getPublishedV2Config($departmentId);
return [
'config_source' => $configSource,
'config' => is_array($version['config'] ?? null) ? (array)$version['config'] : null,
'version_id' => isset($version['version_id']) ? (int)$version['version_id'] : null,
];
}
$draft = (new selfserve_config_versions_o())->selectLatestByDepartmentAndStatus(
$departmentId,
selfserve_config_versioning::STATUS_DRAFT
);
if (!$draft->exists()) {
return [
'config_source' => $configSource,
'config' => $versioning->snapshotLegacyConfig($departmentId),
'version_id' => null,
];
}
$config = (array)($draft->config_json->value() ?? []);
if (!$versioning->isV2Config($config)) {
$config = $versioning->migrateLegacyConfigToV2($config + ['department_id' => $departmentId]);
}
return [
'config_source' => $configSource,
'config' => $config,
'version_id' => (int)$draft->id,
];
}
/**
* @param array<string,mixed> $payload
* @param array<string,bool> $permissions
*/
private function resolveSimulationConfigSource(array $payload, array $permissions): string
{
$configSource = strtolower(trim((string)($payload['config_source'] ?? 'draft')));
if (!in_array($configSource, ['draft', 'published'], true)) {
$configSource = 'draft';
}
if ($configSource === 'draft' && !($permissions['can_view'] ?? false)) {
throw new \RuntimeException('Draft studio simulation requires list_department_selfserve_config_versions permission.');
}
return $configSource;
}
/** /**
* @param array<string,mixed> $payload * @param array<string,mixed> $payload
* @param array<string,bool> $permissions * @param array<string,bool> $permissions
@@ -600,21 +659,11 @@ class selfserve_studio_graph
?callable $progressCallback = null ?callable $progressCallback = null
): array ): array
{ {
$configSource = strtolower(trim((string)($payload['config_source'] ?? 'draft')));
if (!in_array($configSource, ['draft', 'published'], true)) {
$configSource = 'draft';
}
$versioning = new selfserve_config_versioning(); $versioning = new selfserve_config_versioning();
if ($configSource === 'published') { $configContext = $this->loadSimulationConfig($departmentId, $payload, $permissions, $versioning);
$version = $versioning->getPublishedV2Config($departmentId); $configSource = $configContext['config_source'];
$config = is_array($version['config'] ?? null) ? (array)$version['config'] : null; $config = $configContext['config'];
$versionId = isset($version['version_id']) ? (int)$version['version_id'] : null; $versionId = $configContext['version_id'];
} else {
$version = $versioning->ensureDraftFromLegacy($departmentId, $userId, false);
$config = is_array($version['config'] ?? null) ? (array)$version['config'] : $versioning->snapshotLegacyConfig($departmentId);
$versionId = isset($version['id']) ? (int)$version['id'] : null;
}
$includeHardware = filter_var($payload['include_hardware'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); $includeHardware = filter_var($payload['include_hardware'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
$includeHardware = $includeHardware !== false; $includeHardware = $includeHardware !== false;
@@ -678,7 +727,11 @@ class selfserve_studio_graph
$vehicleTypeIds[] = null; $vehicleTypeIds[] = null;
} }
$maxStates = $this->pathLimit($payload['max_states'] ?? null); $maxStates = $this->pathLimit(
$payload['max_states'] ?? null,
self::DEFAULT_PATH_MAX_STATES,
self::MAX_PATH_MAX_STATES
);
$reg = trim((string)($payload['reg'] ?? $defaults['reg'] ?? 'TEST123')); $reg = trim((string)($payload['reg'] ?? $defaults['reg'] ?? 'TEST123'));
if ($reg === '') { if ($reg === '') {
$reg = 'TEST123'; $reg = 'TEST123';
@@ -694,14 +747,18 @@ class selfserve_studio_graph
$stateCount = 0; $stateCount = 0;
$terminalPathCount = 0; $terminalPathCount = 0;
$questionIds = []; $questionIds = [];
$pathSampleLimit = $this->pathLimit($payload['path_sample_limit'] ?? null); $pathSampleLimit = $this->pathLimit(
$payload['path_sample_limit'] ?? null,
self::DEFAULT_PATH_SAMPLE_LIMIT,
self::MAX_PATH_SAMPLE_LIMIT
);
$paths = []; $paths = [];
$scenarioCount = max(1, count($vehicleTypeIds)); $scenarioCount = max(1, count($vehicleTypeIds));
$confirmationRows = $this->loadPathConfirmationRows($departmentId, $versionId, $laneId, $vehicleTypeId, $configSource); $confirmationRows = $this->loadPathConfirmationRows($departmentId, $versionId, $laneId, $vehicleTypeId, $configSource);
foreach ($vehicleTypeIds as $scenarioIndex => $scenarioVehicleTypeId) { foreach ($vehicleTypeIds as $scenarioIndex => $scenarioVehicleTypeId) {
$remainingStates = $maxStates === null ? null : $maxStates - $stateCount; $remainingStates = $maxStates - $stateCount;
if ($remainingStates !== null && $remainingStates <= 0) { if ($remainingStates <= 0) {
$truncated = true; $truncated = true;
break; break;
} }
@@ -759,7 +816,7 @@ class selfserve_studio_graph
$projectionOptions = [ $projectionOptions = [
'scope' => $scenarioScope, 'scope' => $scenarioScope,
'max_states' => $remainingStates, 'max_states' => $remainingStates,
'path_sample_limit' => $pathSampleLimit === null ? null : max(0, $pathSampleLimit - count($paths)), 'path_sample_limit' => max(0, $pathSampleLimit - count($paths)),
'progress_callback' => function (array $projection) use ( 'progress_callback' => function (array $projection) use (
$progressCallback, $progressCallback,
&$outcomes, &$outcomes,
@@ -787,7 +844,7 @@ class selfserve_studio_graph
$partialOutcomes = array_merge($outcomes, array_values((array)($projection['outcomes'] ?? []))); $partialOutcomes = array_merge($outcomes, array_values((array)($projection['outcomes'] ?? [])));
$partialPaths = array_merge($paths, array_values((array)($projection['paths'] ?? []))); $partialPaths = array_merge($paths, array_values((array)($projection['paths'] ?? [])));
if ($pathSampleLimit !== null && count($partialPaths) > $pathSampleLimit) { if (count($partialPaths) > $pathSampleLimit) {
$partialPaths = array_slice($partialPaths, 0, $pathSampleLimit); $partialPaths = array_slice($partialPaths, 0, $pathSampleLimit);
} }
@@ -839,12 +896,6 @@ class selfserve_studio_graph
}, },
'confirmation_rows' => $confirmationRows, 'confirmation_rows' => $confirmationRows,
]; ];
if ($remainingStates === null) {
unset($projectionOptions['max_states']);
}
if ($pathSampleLimit === null) {
unset($projectionOptions['path_sample_limit']);
}
$projection = $this->projectPathOutcomesFromSimulator($simulate, $projectionOptions); $projection = $this->projectPathOutcomesFromSimulator($simulate, $projectionOptions);
foreach ((array)($projection['outcomes'] ?? []) as $outcome) { foreach ((array)($projection['outcomes'] ?? []) as $outcome) {
if (is_array($outcome)) { if (is_array($outcome)) {
@@ -855,7 +906,7 @@ class selfserve_studio_graph
if (!is_array($path)) { if (!is_array($path)) {
continue; continue;
} }
if ($pathSampleLimit === null || count($paths) < $pathSampleLimit) { if (count($paths) < $pathSampleLimit) {
$paths[] = $path; $paths[] = $path;
} }
} }
@@ -918,9 +969,18 @@ class selfserve_studio_graph
*/ */
public function projectPathOutcomesFromSimulator(callable $simulate, array $options = []): array public function projectPathOutcomesFromSimulator(callable $simulate, array $options = []): array
{ {
$maxStates = $this->pathLimit($options['max_states'] ?? null); $maxStates = $this->pathLimit(
$options['max_states'] ?? null,
self::DEFAULT_PATH_MAX_STATES,
self::MAX_PATH_MAX_STATES
);
$sampleLimit = max(1, min(10, (int)($options['sample_limit'] ?? 5))); $sampleLimit = max(1, min(10, (int)($options['sample_limit'] ?? 5)));
$pathSampleLimit = $this->pathLimit($options['path_sample_limit'] ?? null); $pathSampleLimit = $this->pathLimit(
$options['path_sample_limit'] ?? null,
self::DEFAULT_PATH_SAMPLE_LIMIT,
self::MAX_PATH_SAMPLE_LIMIT,
0
);
$progressCallback = is_callable($options['progress_callback'] ?? null) ? $options['progress_callback'] : null; $progressCallback = is_callable($options['progress_callback'] ?? null) ? $options['progress_callback'] : null;
$progressIntervalStates = max(1, (int)($options['progress_interval_states'] ?? 128)); $progressIntervalStates = max(1, (int)($options['progress_interval_states'] ?? 128));
$scope = is_array($options['scope'] ?? null) ? (array)$options['scope'] : []; $scope = is_array($options['scope'] ?? null) ? (array)$options['scope'] : [];
@@ -938,7 +998,7 @@ class selfserve_studio_graph
$truncated = false; $truncated = false;
while ($stack !== []) { while ($stack !== []) {
if ($maxStates !== null && $stateCount >= $maxStates) { if ($stateCount >= $maxStates) {
$truncated = true; $truncated = true;
break; break;
} }
@@ -997,7 +1057,7 @@ class selfserve_studio_graph
$terminalPathCount++; $terminalPathCount++;
$chain = is_array($state['chain'] ?? null) ? (array)$state['chain'] : []; $chain = is_array($state['chain'] ?? null) ? (array)$state['chain'] : [];
$this->addPathOutcomeGroup($groups, $simulation, $chain, $scope, $sampleLimit); $this->addPathOutcomeGroup($groups, $simulation, $chain, $scope, $sampleLimit);
if ($pathSampleLimit === null || count($paths) < $pathSampleLimit) { if (count($paths) < $pathSampleLimit) {
$paths[] = $this->pathResultFromSimulation($simulation, $chain, $scope); $paths[] = $this->pathResultFromSimulation($simulation, $chain, $scope);
} }
@@ -1676,7 +1736,7 @@ class selfserve_studio_graph
* @param array<string,mixed> $config * @param array<string,mixed> $config
* @param array<string,mixed> $operation * @param array<string,mixed> $operation
*/ */
private function applyConfigOperation(int $departmentId, array &$config, array $operation): void private function applyConfigOperation(int $departmentId, array &$config, array $operation, array $permissions = []): void
{ {
$action = strtolower((string)($operation['action'] ?? '')); $action = strtolower((string)($operation['action'] ?? ''));
$entity = $this->normalizeEntity((string)($operation['entity'] ?? $operation['type'] ?? '')); $entity = $this->normalizeEntity((string)($operation['entity'] ?? $operation['type'] ?? ''));
@@ -1703,7 +1763,7 @@ class selfserve_studio_graph
throw new \RuntimeException('Studio graph operation is missing entity.'); throw new \RuntimeException('Studio graph operation is missing entity.');
} }
if ($entity === 'lane') { if ($entity === 'lane') {
$this->applyLaneOperation($departmentId, $action, $id, $data); $this->applyLaneOperation($departmentId, $action, $id, $data, $permissions);
return; return;
} }
if ($entity === 'rule') { if ($entity === 'rule') {
@@ -2761,7 +2821,7 @@ class selfserve_studio_graph
/** /**
* @param array<string,mixed> $operation * @param array<string,mixed> $operation
*/ */
private function applyOperation(int $departmentId, array $operation): void private function applyOperation(int $departmentId, array $operation, array $permissions = []): void
{ {
$action = strtolower((string)($operation['action'] ?? '')); $action = strtolower((string)($operation['action'] ?? ''));
$entity = $this->normalizeEntity((string)($operation['entity'] ?? $operation['type'] ?? '')); $entity = $this->normalizeEntity((string)($operation['entity'] ?? $operation['type'] ?? ''));
@@ -2784,7 +2844,7 @@ class selfserve_studio_graph
throw new \RuntimeException('Studio graph operation is missing entity.'); throw new \RuntimeException('Studio graph operation is missing entity.');
} }
if ($entity === 'lane') { if ($entity === 'lane') {
$this->applyLaneOperation($departmentId, $action, $id, $data); $this->applyLaneOperation($departmentId, $action, $id, $data, $permissions);
return; return;
} }
@@ -2810,12 +2870,14 @@ class selfserve_studio_graph
/** /**
* @param array<string,mixed> $data * @param array<string,mixed> $data
*/ */
private function applyLaneOperation(int $departmentId, string $action, int $id, array $data): void private function applyLaneOperation(int $departmentId, string $action, int $id, array $data, array $permissions = []): void
{ {
if (!$this->tableExists('department_lanes')) { if (!$this->tableExists('department_lanes')) {
throw new \RuntimeException('Department lanes are not available.'); throw new \RuntimeException('Department lanes are not available.');
} }
$this->assertLaneOperationAuthorized($action, $data, $permissions);
if ($action === 'create') { if ($action === 'create') {
$this->createLane($departmentId, $data); $this->createLane($departmentId, $data);
return; return;
@@ -2842,6 +2904,37 @@ class selfserve_studio_graph
throw new \RuntimeException('Unsupported studio lane operation: ' . $action); throw new \RuntimeException('Unsupported studio lane operation: ' . $action);
} }
/**
* @param array<string,mixed> $data
* @param array<string,bool> $permissions
*/
private function assertLaneOperationAuthorized(string $action, array $data, array $permissions): void
{
if ($action === 'create' && !($permissions['can_add_department_lane'] ?? false)) {
throw new \RuntimeException('Missing permission: add_department_lane.');
}
if (in_array($action, ['update', 'delete'], true) && !($permissions['can_edit_department_lane'] ?? false)) {
throw new \RuntimeException('Missing permission: edit_department_lane.');
}
if (!in_array($action, ['create', 'update'], true)) {
return;
}
$relayFields = [
'relay_in_id',
'relay_out_id',
'relay_machine_id',
'relay_machine_program_picker_id',
'relay_machine_cleaner_id',
];
foreach ($relayFields as $field) {
if (array_key_exists($field, $data) && !($permissions['modules_shelly_config'] ?? false)) {
throw new \RuntimeException('Missing permission: modules_shelly_config.');
}
}
}
/** /**
* @param array<string,mixed> $data * @param array<string,mixed> $data
*/ */
@@ -4240,14 +4333,18 @@ class selfserve_studio_graph
return null; return null;
} }
private function pathLimit(mixed $value): ?int private function pathLimit(mixed $value, int $default, int $max, int $min = 1): int
{ {
if ($value === null || $value === '') { if ($value === null || $value === '') {
return null; return max($min, min($max, $default));
} }
$parsed = (int)$value; $parsed = (int)$value;
return $parsed > 0 ? $parsed : null; if ($parsed < $min) {
return max($min, min($max, $default));
}
return min($max, $parsed);
} }
/** /**
@@ -4319,8 +4416,10 @@ class selfserve_studio_graph
int $terminalPathCount, int $terminalPathCount,
array $questionIds, array $questionIds,
array $progress = [], array $progress = [],
array $confirmationRows = [] ?array $confirmationRows = null
): array { ): array {
$confirmationRows = $confirmationRows ?? [];
usort($outcomes, static fn(array $left, array $right): int => ((int)($right['path_count'] ?? 0) <=> (int)($left['path_count'] ?? 0)) usort($outcomes, static fn(array $left, array $right): int => ((int)($right['path_count'] ?? 0) <=> (int)($left['path_count'] ?? 0))
?: strcmp((string)($left['summary'] ?? ''), (string)($right['summary'] ?? ''))); ?: strcmp((string)($left['summary'] ?? ''), (string)($right['summary'] ?? '')));
foreach ($outcomes as $index => &$outcome) { foreach ($outcomes as $index => &$outcome) {
@@ -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);
@@ -395,7 +425,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)
@@ -405,8 +435,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),
@@ -450,9 +484,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());
@@ -2962,7 +2999,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;
})); }));
} }
@@ -3005,7 +3042,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;
})); }));
} }
@@ -3049,7 +3086,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;
})); }));
} }
@@ -3188,6 +3225,86 @@ 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 [
@@ -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
);
}
}
@@ -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:
@@ -4,7 +4,9 @@ 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_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_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;
@@ -29,6 +31,11 @@ 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()
{ {
@@ -36,10 +43,12 @@ class selfserve_c
$this->allowUpdate([ $this->allowUpdate([
selfserve_enabled_c::class, selfserve_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->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();
} }
} }
@@ -38,6 +38,49 @@ use objects\department_variables_o;
trait selfserve_lane_command_t trait selfserve_lane_command_t
{ {
/**
* Acquire an atomic per-lane START lock before performing physical side effects.
*/
protected function acquireLaneStartCommandLock(): string
{
if (!defined('redis') || !method_exists(redis, 'set_if_absent_with_expiration')) {
throw new \RuntimeException('Cannot start lane: START lock is unavailable.');
}
$token = bin2hex(random_bytes(16));
$lock_key = $this->getLaneStartCommandLockKey();
if (!redis->set_if_absent_with_expiration($lock_key, $token, 30)) {
throw new \RuntimeException("Cannot start lane: Lane is not available.");
}
return $token;
}
protected function releaseLaneStartCommandLock(string $token): void
{
if (!defined('redis')) {
return;
}
$lock_key = $this->getLaneStartCommandLockKey();
try {
if (method_exists(redis, 'get') && redis->get($lock_key) !== $token) {
return;
}
if (method_exists(redis, 'delete')) {
redis->delete($lock_key);
}
} catch (\Throwable) {
// The lock has a short TTL, so release failures must not mask START results.
}
}
protected function getLaneStartCommandLockKey(): string
{
return 'selfserve_lane_start_command_lock_' . (int)$this->id;
}
/** /**
* Determine if the lane and its department have self-serve enabled. * Determine if the lane and its department have self-serve enabled.
* This method is intentionally protected to allow tests to override * This method is intentionally protected to allow tests to override
@@ -128,13 +171,12 @@ trait selfserve_lane_command_t
* Ensure machine relay is ON when a wash starts, when it is allowed by configuration. * Ensure machine relay is ON when a wash starts, when it is allowed by configuration.
* If machine relay is not configured, this is a no-op. * If machine relay is not configured, this is a no-op.
*/ */
protected function setMachineRelayStatusForWashStart(): void protected function setMachineRelayStatusForWashStart(?selfserve_lane_command_arguments $arguments = null): void
{ {
if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE)) { if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE)) {
return; return;
} }
$active_wash = new selfserve_wash_flow(); if ($this->isMachineWashSelectedAndAvailableForStart($arguments)) {
if ($active_wash->isMachineAllowedToStartWash($this->id)) {
try { try {
$this->setMachineRelayStatusHard(true); $this->setMachineRelayStatusHard(true);
} catch (\Throwable) { } catch (\Throwable) {
@@ -150,6 +192,116 @@ trait selfserve_lane_command_t
} }
} }
/**
* Keep the program picker relay aligned with the selected wash mode at START.
* It is ON only when the customer explicitly selected machine wash.
*/
protected function setProgramPickerRelayStatusForWashStart(?selfserve_lane_command_arguments $arguments = null): void
{
if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE_PROGRAM_PICKER)) {
return;
}
try {
$shouldEnable = $this->isExplicitMachineWashModeSelectedForStart($arguments)
&& $this->isMachineWashSelectedAndAvailableForStart($arguments);
$this->setMachineProgramPickerRelayStatusHard($shouldEnable);
} catch (\Throwable) {
// Best effort only; wash start must continue.
}
}
protected function setProgramPickerRelayStatusFromSelectedServiceForWashStart(?selfserve_lane_command_arguments $arguments = null): void
{
if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE_PROGRAM_PICKER)) {
return;
}
try {
$this->setMachineProgramPickerRelayStatusHard($this->shouldEnableProgramPickerRelayForWashStart($arguments));
} catch (\Throwable) {
// Best effort only; wash start must continue.
}
}
protected function isMachineWashSelectedAndAvailableForStart(?selfserve_lane_command_arguments $arguments = null): bool
{
if (!$this->shouldEnableSelectedMachineServiceForWashStart($arguments)) {
return false;
}
try {
$reg = method_exists($this, 'getLicensePlate') ? trim((string)$this->getLicensePlate()) : '';
if ($reg === '') {
return false;
}
$customerNumber = method_exists($this, 'getCustomerNumber') ? (int)$this->getCustomerNumber() : null;
$snapshot = (new selfserve_wash_flow())->previewVehicleEligibility(
(int)$this->id,
$reg,
$customerNumber !== null && $customerNumber > 0 ? $customerNumber : null
);
return (bool)($snapshot['allowed'] ?? false);
} catch (\Throwable) {
return false;
}
}
protected function shouldEnableSelectedMachineServiceForWashStart(?selfserve_lane_command_arguments $arguments = null): bool
{
if (!$this->isMachineWashModeSelectedForStart($arguments)) {
return false;
}
return $this->isMachineServiceSelectedForWashStart();
}
protected function shouldEnableProgramPickerRelayForWashStart(?selfserve_lane_command_arguments $arguments = null): bool
{
return $this->isExplicitMachineWashModeSelectedForStart($arguments)
&& $this->isMachineServiceSelectedForWashStart();
}
protected function isExplicitMachineWashModeSelectedForStart(?selfserve_lane_command_arguments $arguments = null): bool
{
return $arguments !== null && $arguments->wash_mode === selfserve_studio_actions::MODE_MACHINE;
}
protected function isMachineWashModeSelectedForStart(?selfserve_lane_command_arguments $arguments = null): bool
{
if ($arguments !== null && $arguments->wash_mode === selfserve_studio_actions::MODE_MANUAL) {
return false;
}
if ($arguments !== null && $arguments->wash_mode === selfserve_studio_actions::MODE_MACHINE) {
return true;
}
return $this->isMachineServiceSelectedForWashStart();
}
protected function isMachineServiceSelectedForWashStart(): bool
{
try {
if (method_exists($this, 'getLaneCache') && defined(self::class . '::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES')) {
$services = $this->getLaneCache((int)$this->id, self::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES);
if (is_array($services)) {
foreach ($services as $service) {
if (strtoupper((string)$service) === 'MACHINE') {
return true;
}
}
}
}
} catch (\Throwable) {
// Fall through to fail-closed when the selected service cache is unavailable.
}
return false;
}
protected function openEntrancePortForWashStart(): void protected function openEntrancePortForWashStart(): void
{ {
try { try {
@@ -231,30 +383,26 @@ trait selfserve_lane_command_t
protected function runRelaySideEffectsForWashStart(selfserve_lane_command_arguments $arguments): void protected function runRelaySideEffectsForWashStart(selfserve_lane_command_arguments $arguments): void
{ {
if ($arguments->defer_relay_side_effects) { if ($arguments->defer_relay_side_effects) {
$this->setProgramPickerRelayStatusFromSelectedServiceForWashStart($arguments);
return; return;
} }
// Ensure cleaner relay is enabled whenever wash starts.
$this->turnOnCleanerRelayForWashStart(); $this->turnOnCleanerRelayForWashStart();
// Ensure the machine relay is ON when a wash starts, when it is allowed. $this->setProgramPickerRelayStatusForWashStart($arguments);
$this->setMachineRelayStatusForWashStart(); $this->setMachineRelayStatusForWashStart($arguments);
} }
protected function resolveSelfServeActionWashModeForStart(): string protected function resolveSelfServeActionWashModeForStart(?selfserve_lane_command_arguments $arguments = null): string
{ {
try { if ($arguments !== null && in_array($arguments->wash_mode, [
if (method_exists($this, 'getLaneCache') && defined(self::class . '::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES')) { selfserve_studio_actions::MODE_MANUAL,
$services = $this->getLaneCache((int)$this->id, self::CACHE_SELFSERVE_LANE_KEY_ALLOWED_SERVICES); selfserve_studio_actions::MODE_MACHINE,
if (is_array($services)) { ], true)) {
foreach ($services as $service) { return $arguments->wash_mode;
if (strtoupper((string)$service) === 'MACHINE') { }
return selfserve_studio_actions::MODE_MACHINE;
} if ($this->isMachineServiceSelectedForWashStart()) {
} return selfserve_studio_actions::MODE_MACHINE;
}
}
} catch (\Throwable) {
// Fall through to manual mode when the cached service set is unavailable.
} }
return selfserve_studio_actions::MODE_MANUAL; return selfserve_studio_actions::MODE_MANUAL;
@@ -339,14 +487,16 @@ trait selfserve_lane_command_t
} }
/** /**
* Disable relays after STOP in deterministic order: * Disable relays before opening exit gates in deterministic order:
* 1. Cleaner relay * 1. Cleaner relay
* 2. Machine relay * 2. Program picker relay
* 3. Machine relay
*/ */
protected function turnOffRelaysAfterStop(): void protected function turnOffRelaysAfterStop(): void
{ {
$relays = [ $relays = [
selfserve_lane_relay::MACHINE_CLEANER, selfserve_lane_relay::MACHINE_CLEANER,
selfserve_lane_relay::MACHINE_PROGRAM_PICKER,
selfserve_lane_relay::MACHINE, selfserve_lane_relay::MACHINE,
]; ];
@@ -388,7 +538,8 @@ trait selfserve_lane_command_t
$this->id, $this->id,
$this->getLicensePlate() ?: null, $this->getLicensePlate() ?: null,
$this->getCustomerNumber() ?: null, $this->getCustomerNumber() ?: null,
method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null,
false
); );
} catch (\Throwable) { } catch (\Throwable) {
// Session completion must not block STOP flow. // Session completion must not block STOP flow.
@@ -509,39 +660,48 @@ trait selfserve_lane_command_t
// Validate customer number // Validate customer number
if (!is_numeric($customer_number) || (int)$customer_number <= 0) throw new \InvalidArgumentException("Invalid customer number: " . $customer_number); if (!is_numeric($customer_number) || (int)$customer_number <= 0) throw new \InvalidArgumentException("Invalid customer number: " . $customer_number);
if (!(new users_o())->getUserByCustomerNumber((int)$customer_number)->exists()) throw new \InvalidArgumentException("Customer number does not exist: " . $customer_number); if (!(new users_o())->getUserByCustomerNumber((int)$customer_number)->exists()) throw new \InvalidArgumentException("Customer number does not exist: " . $customer_number);
$previous_customer_number = $this->getCustomerNumber(); $start_lock_token = $this->acquireLaneStartCommandLock();
$previous_license_plate = $this->getLicensePlate();
// Set the customer number and license plate
$this->setCustomerNumber($customer_number);
$this->setLicensePlate($license_plate);
try { try {
// Open the entrance port before marking the lane occupied. Gateway timeouts are // Re-check availability after taking the START lock so concurrent requests cannot
// ambiguous because the relay may already have received the pulse. // both pass the preflight check and trigger the physical entrance relay.
$this->openEntrancePortForWashStart(); if (!$this->getLaneStatus()->equals(selfserve_lane_status::AVAILABLE)) throw new \RuntimeException("Cannot start lane: Lane is not available.");
$this->turnOnCleanerRelayForWashStart(); $previous_customer_number = $this->getCustomerNumber();
} catch (\Throwable $e) { $previous_license_plate = $this->getLicensePlate();
$this->setCustomerNumber($previous_customer_number); $previous_status = $this->getLaneStatus();
$this->setLicensePlate($previous_license_plate); // Set the customer number and license plate
$this->setLaneState(selfserve_lane_state::IDLE); $this->setCustomerNumber($customer_number);
throw $e; $this->setLicensePlate($license_plate);
// Mark the lane occupied before any physical relay or gate side effects.
$this->setLaneStatus(selfserve_lane_status::OCCUPIED);
$this->runRelaySideEffectsForWashStart($arguments);
$this->runPublishedStudioActions(
selfserve_studio_actions::EVENT_WASH_START_COMMAND,
$this->resolveSelfServeActionWashModeForStart($arguments),
[
'customer_number' => (int)$customer_number,
'reg' => $license_plate,
]
);
try {
// Gateway timeouts are ambiguous because the relay may already have received
// the pulse, so openEntrancePortForWashStart() reports them and continues.
$this->openEntrancePortForWashStart();
} catch (\Throwable $e) {
$this->setCustomerNumber($previous_customer_number);
$this->setLicensePlate($previous_license_plate);
$this->setLaneStatus($previous_status);
$this->setLaneState(selfserve_lane_state::IDLE);
throw $e;
}
// Set the lane state to IN_WASH
$this->setLaneState(selfserve_lane_state::IN_WASH);
// Start the wash timer
$this->setWashStartTime(time());
// Log the lane start event
$this->logLaneAction(selfserve_lane_log_action::START_WASH);
} finally {
$this->releaseLaneStartCommandLock($start_lock_token);
} }
// Set the lane status to OCCUPIED when started
$this->setLaneStatus(selfserve_lane_status::OCCUPIED);
// Set the lane state to IN_WASH
$this->setLaneState(selfserve_lane_state::IN_WASH);
// Start the wash timer
$this->setWashStartTime(time());
$this->runPublishedStudioActions(
selfserve_studio_actions::EVENT_WASH_START_COMMAND,
$this->resolveSelfServeActionWashModeForStart(),
[
'customer_number' => (int)$customer_number,
'reg' => $license_plate,
]
);
$this->runRelaySideEffectsForWashStart($arguments);
// Log the lane start event
$this->logLaneAction(selfserve_lane_log_action::START_WASH);
break; break;
case selfserve_lane_command::STOP: case selfserve_lane_command::STOP:
// Require lane to be occupied before stopping // Require lane to be occupied before stopping
@@ -552,6 +712,8 @@ trait selfserve_lane_command_t
} }
// Snapshot the physical machine ON signal before session completion/reset. // Snapshot the physical machine ON signal before session completion/reset.
$machine_start_triggered = $this->hasMachineStartSignalForStop(); $machine_start_triggered = $this->hasMachineStartSignalForStop();
// Turn off relays before any configured or default exit gate opens.
$this->turnOffRelaysAfterStop();
$this->runPublishedStudioActions( $this->runPublishedStudioActions(
selfserve_studio_actions::EVENT_WASH_STOP_COMMAND, selfserve_studio_actions::EVENT_WASH_STOP_COMMAND,
$machine_start_triggered ? selfserve_studio_actions::MODE_MACHINE : selfserve_studio_actions::MODE_MANUAL, $machine_start_triggered ? selfserve_studio_actions::MODE_MACHINE : selfserve_studio_actions::MODE_MANUAL,
@@ -564,8 +726,6 @@ trait selfserve_lane_command_t
// Open the exit port. Gateway timeouts are ambiguous because // Open the exit port. Gateway timeouts are ambiguous because
// the relay may already have received the pulse. // the relay may already have received the pulse.
$this->openExitPortForWashStop(); $this->openExitPortForWashStop();
// Turn off relays in deterministic order after STOP
$this->turnOffRelaysAfterStop();
// Log the lane stop event // Log the lane stop event
$this->logLaneAction(selfserve_lane_log_action::STOP_WASH); $this->logLaneAction(selfserve_lane_log_action::STOP_WASH);
// Invoice the customer // Invoice the customer
@@ -223,9 +223,8 @@ trait selfserve_lane_invoice_t
{ {
$billing_customer_number = $this->getCustomerNumber(); $billing_customer_number = $this->getCustomerNumber();
$draft_customer_number = (new economic())->getTransactionDraftCustomerNumber(); $draft_customer_number = (new economic())->getTransactionDraftCustomerNumber();
$order_customer_number = $draft_customer_number ?? $billing_customer_number;
$order = (new orders_o())->add( $order = (new orders_o())->add(
$order_customer_number, $billing_customer_number,
self::INVOICE_SYSTEM_USER_ID, self::INVOICE_SYSTEM_USER_ID,
'', '',
'', '',
@@ -17,6 +17,7 @@ trait selfserve_lane_port_controller_t
{ {
private const DEMO_RELAY_ID_PREFIX = 'demo-'; private const DEMO_RELAY_ID_PREFIX = 'demo-';
private const DEFAULT_PORT_OPEN_TOGGLE_AFTER_SECONDS = 1; private const DEFAULT_PORT_OPEN_TOGGLE_AFTER_SECONDS = 1;
private const MAX_PORT_OPEN_TOGGLE_AFTER_SECONDS = 5;
/** /**
* Open the lane port * Open the lane port
@@ -105,7 +106,7 @@ trait selfserve_lane_port_controller_t
private function normalizePortOpenToggleAfter(?int $toggle_after_seconds): int private function normalizePortOpenToggleAfter(?int $toggle_after_seconds): int
{ {
if ($toggle_after_seconds !== null && $toggle_after_seconds > 0) { if ($toggle_after_seconds !== null && $toggle_after_seconds > 0) {
return $toggle_after_seconds; return min($toggle_after_seconds, self::MAX_PORT_OPEN_TOGGLE_AFTER_SECONDS);
} }
return self::DEFAULT_PORT_OPEN_TOGGLE_AFTER_SECONDS; return self::DEFAULT_PORT_OPEN_TOGGLE_AFTER_SECONDS;
@@ -12,6 +12,7 @@ use modules\selfserve\helpers\selfserve_lane_relay;
use modules\selfserve\helpers\selfserve_lane_services; use modules\selfserve\helpers\selfserve_lane_services;
use modules\selfserve\helpers\selfserve_lane_status; use modules\selfserve\helpers\selfserve_lane_status;
use modules\shelly\helpers\shelly_request_body_get_states; use modules\shelly\helpers\shelly_request_body_get_states;
use objects\selfserve_wash_sessions_o;
trait selfserve_lane_relay_controller_t trait selfserve_lane_relay_controller_t
{ {
@@ -107,14 +108,16 @@ trait selfserve_lane_relay_controller_t
} }
/** /**
* Set MACHINE relay status directly. * Set MACHINE relay status using the same guards as manual relay controls.
* @param bool $on true to turn on, false to turn off * @param bool $on true to turn on, false to turn off
* @return bool * @return bool
* @throws \Exception * @throws \Exception
*/ */
public function setMachineRelayStatus(bool $on): bool public function setMachineRelayStatus(bool $on): bool
{ {
return $this->setRelayStatus(selfserve_lane_relay::MACHINE, $on); return $on
? $this->turnOnRelay(selfserve_lane_relay::MACHINE)
: $this->turnOffRelay(selfserve_lane_relay::MACHINE);
} }
/** /**
@@ -124,7 +127,7 @@ trait selfserve_lane_relay_controller_t
*/ */
public function setMachineRelayStatusHard(bool $on): bool public function setMachineRelayStatusHard(bool $on): bool
{ {
return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $on); return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE, $on);
} }
/** /**
@@ -915,7 +918,38 @@ trait selfserve_lane_relay_controller_t
} }
} }
return $this->sendRelaySwitchCommand($relay, true, $duration); $result = $this->sendRelaySwitchCommand($relay, true, $duration);
if ($result && $relay === selfserve_lane_relay::MACHINE) {
$this->markLatestSelfServeSessionRelayEnabledForLane();
}
return $result;
}
protected function markLatestSelfServeSessionRelayEnabledForLane(): void
{
try {
$customerNumber = (int)$this->getCustomerNumber();
$session = (new selfserve_wash_sessions_o())->selectLatestOpenByLane(
(int)$this->id,
$customerNumber > 0 ? $customerNumber : null
);
if (!$session->exists() && $customerNumber > 0) {
$session = (new selfserve_wash_sessions_o())->selectLatestOpenByLane((int)$this->id);
}
if (!$session->exists() || (bool)$session->machine_relay_enabled->value() === true) {
return;
}
$session->markRelayEnabled();
} catch (\Throwable $e) {
error_log(
'Failed to synchronize self-serve machine relay session state for lane '
. (int)$this->id
. ': '
. $e->getMessage()
);
}
} }
/** /**
@@ -0,0 +1,29 @@
<?php
namespace slack\config;
use Exception;
use traits\module_config_variable;
class slack_customer_registration_webhook_url_c
{
use module_config_variable;
/**
* @throws Exception
*/
public function __construct()
{
self::setupConfigVariable(
'Slack',
'customer_registration_webhook_url',
'string',
false,
null,
'Slack webhook URL used for successful customer registration notifications',
'https://hooks.slack.com/services/...',
true,
''
);
}
}
@@ -0,0 +1,25 @@
<?php
namespace slack;
require_once WD . '/modules/slack/config/slack_customer_registration_webhook_url_c.php';
use slack\config\slack_customer_registration_webhook_url_c;
use traits\module_config_t;
class slack_c
{
use module_config_t;
public slack_customer_registration_webhook_url_c $customer_registration_webhook_url;
public function __construct()
{
$this->setupConfig('Slack');
$this->allowUpdate([
slack_customer_registration_webhook_url_c::class,
]);
$this->customer_registration_webhook_url = new slack_customer_registration_webhook_url_c();
}
}
@@ -20,7 +20,11 @@ if (!is_numeric($certificate_id) || $certificate_id < 1) {
return; return;
} }
// TODO: Add authentication here // Require a valid static token before serving certificates
if (!isset($_GET['secret_token']) || $_GET['secret_token'] !== $WORDPRESS_STATIC_TOKEN) {
header('HTTP/1.0 401 Unauthorized');
return;
}
// Check if the certificate exists in the /output/certificates folder // Check if the certificate exists in the /output/certificates folder
if (!file_exists("../output/certificates/wash_certificate_" . $certificate_id . ".pdf")) { if (!file_exists("../output/certificates/wash_certificate_" . $certificate_id . ".pdf")) {
@@ -34,4 +38,4 @@ header('Content-Disposition: attachment; filename="wash certificate ' . $certifi
// Output the certificate // Output the certificate
readfile("../output/certificates/wash_certificate_" . $certificate_id . ".pdf"); readfile("../output/certificates/wash_certificate_" . $certificate_id . ".pdf");
exit; exit;
@@ -104,7 +104,7 @@ if ($wash_certificate_store->washCertificateExists($_GET['bookingId'])) {
$success = $wash_certificate_store->uploadFile("wash_certificate_" . $_GET['bookingId'] . ".pdf", dirname(__FILE__) . "/output/certificates/wash_certificate_" . $_GET['bookingId'] . ".pdf"); $success = $wash_certificate_store->uploadFile("wash_certificate_" . $_GET['bookingId'] . ".pdf", dirname(__FILE__) . "/output/certificates/wash_certificate_" . $_GET['bookingId'] . ".pdf");
// If the certificate was uploaded successfully, delete the local copy // If the certificate was uploaded successfully, delete the local copy
if ($success) { if ($success) {
//unlink(dirname(__FILE__) . "/output/certificates/wash_certificate_" . $_GET['bookingId'] . ".pdf"); unlink(dirname(__FILE__) . "/output/certificates/wash_certificate_" . $_GET['bookingId'] . ".pdf");
// Return the certificate url // Return the certificate url
echo $wash_certificate_store->getWashCertificateDownload($_GET['bookingId']); echo $wash_certificate_store->getWashCertificateDownload($_GET['bookingId']);
exit; exit;
@@ -131,7 +131,7 @@ $generatedCertificatePath = "output/certificates/wash_certificate_" . $_GET['boo
$wash_certificate_store->uploadFile($generatedCertificateName, dirname(__FILE__) . '/' . $generatedCertificatePath); $wash_certificate_store->uploadFile($generatedCertificateName, dirname(__FILE__) . '/' . $generatedCertificatePath);
// Delete the local copy of the certificate // Delete the local copy of the certificate
//unlink(dirname(__FILE__) . '/' . $generatedCertificatePath); unlink(dirname(__FILE__) . '/' . $generatedCertificatePath);
// Set the status of the booking to completed // Set the status of the booking to completed
$booking = new bookings_o(); $booking = new bookings_o();
@@ -7,7 +7,14 @@ use traits\module_config_variable;
class workfeed_api_url_c class workfeed_api_url_c
{ {
use module_config_variable; use module_config_variable {
validateVariableValue as private validateModuleConfigVariableValue;
}
private const TRUSTED_API_HOSTS = [
'api.workfeed.io',
'europe-west1-production-eu-327a3.cloudfunctions.net',
];
/** /**
* @throws Exception * @throws Exception
@@ -20,10 +27,54 @@ class workfeed_api_url_c
'string', 'string',
true, true,
null, null,
'The base URL for the Workfeed API (see docs.workfeed.io)', 'The base URL for the Workfeed API (trusted Workfeed endpoints only; see docs.workfeed.io)',
'https://europe-west1-production-eu-327a3.cloudfunctions.net/api', 'https://europe-west1-production-eu-327a3.cloudfunctions.net/api',
false, false,
'https://europe-west1-production-eu-327a3.cloudfunctions.net/api' 'https://europe-west1-production-eu-327a3.cloudfunctions.net/api'
); );
} }
public function validateVariableValue(mixed $value): bool
{
if (!$this->validateModuleConfigVariableValue($value)) {
return false;
}
return self::isTrustedApiUrl((string)$value);
}
public static function isTrustedApiUrl(string $url): bool
{
$normalizedUrl = self::normalizeApiUrlForValidation($url);
if (filter_var($normalizedUrl, FILTER_VALIDATE_URL) === false) {
return false;
}
$parts = parse_url($normalizedUrl);
if ($parts === false) {
return false;
}
$scheme = strtolower((string)($parts['scheme'] ?? ''));
$host = strtolower((string)($parts['host'] ?? ''));
$port = $parts['port'] ?? null;
return $scheme === 'https'
&& in_array($host, self::TRUSTED_API_HOSTS, true)
&& ($port === null || $port === 443)
&& !isset($parts['user'])
&& !isset($parts['pass']);
}
public static function normalizeApiUrlForValidation(string $url): string
{
$url = trim($url);
if (preg_match('#^https?://#i', $url)) {
return $url;
}
return 'https://' . ltrim($url, '/');
}
} }
@@ -200,6 +200,7 @@ class bookings_o extends db
$washCertificateStatus = $db->escape_string($washCertificateStatus); $washCertificateStatus = $db->escape_string($washCertificateStatus);
$washCertificateUrl = $db->escape_string($washCertificateUrl); $washCertificateUrl = $db->escape_string($washCertificateUrl);
$status = $db->escape_string($status); $status = $db->escape_string($status);
$pickup_bool = (int)$pickup_bool;
// Check if the entry already exists // Check if the entry already exists
$sql = "SELECT * FROM $this->table WHERE id = $id"; $sql = "SELECT * FROM $this->table WHERE id = $id";
$result = $db->query($sql); $result = $db->query($sql);
@@ -606,6 +606,76 @@ class collected_order_invoices_o extends db
self::deleteCached('asArray', $this->id); self::deleteCached('asArray', $this->id);
} }
/**
* Move this invoice collection and all attached orders to another customer.
*
* @return array<string,mixed>
* @throws Exception
*/
public function moveToCustomer(int $target_customer_number): array
{
global $db;
self::requireSelected();
self::requireValidCustomer((string)$target_customer_number);
if ($target_customer_number <= 0) {
throw new Exception('Target customer number must be greater than zero');
}
if (!empty($this->external_id->value()) || $this->booked_invoice_id->value() !== null) {
throw new Exception('Invoice collections with an external or booked invoice cannot be moved');
}
$source_customer_number = (int)$this->customer_number->value();
if ($source_customer_number === $target_customer_number) {
return [
'invoice_collection_id' => (int)$this->id,
'source_customer_number' => $source_customer_number,
'target_customer_number' => $target_customer_number,
'moved_order_ids' => [],
'moved_order_count' => 0,
'changed' => false,
];
}
$invoice_collection_id = (int)$this->id;
$result = $db->query("SELECT id FROM orders WHERE invoice_collection_id = {$invoice_collection_id}");
$order_ids = array_map(
static fn(array $row): int => (int)$row['id'],
$db->fetch_all($result)
);
$db->conn()->begin_transaction();
try {
$this->customer_number->set($target_customer_number);
foreach ( $order_ids as $order_id ) {
$order = (new orders_o())->select($order_id);
if (!$order->exists()) {
continue;
}
$order->customer_id->set($target_customer_number);
$order->objectChanged();
}
$this->objectChanged();
$db->conn()->commit();
} catch (\Throwable $e) {
$db->conn()->rollback();
throw $e;
}
return [
'invoice_collection_id' => $invoice_collection_id,
'source_customer_number' => $source_customer_number,
'target_customer_number' => $target_customer_number,
'moved_order_ids' => $order_ids,
'moved_order_count' => count($order_ids),
'changed' => true,
];
}
/** /**
* Get the external id of the invoice collection * Get the external id of the invoice collection
* @throws Exception If the request was not successful * @throws Exception If the request was not successful
@@ -18,13 +18,18 @@ class customer_password_reset_keys_o extends db
public object_property $updated_at; public object_property $updated_at;
public object_property $deleted_at; public object_property $deleted_at;
const TOKEN_LENGTH = 32; const TOKEN_LENGTH = 32;
const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour const TOKEN_EXPIRY_SECONDS = 72 * 60 * 60; // 72 hours
public function structure(): void public function structure(): void
{ {
$this->setTable('customer_password_reset_keys'); $this->setTable('customer_password_reset_keys');
} }
private function validTokenWhereClause(): string
{
return "deleted_at IS NULL AND created_at >= DATE_SUB(NOW(), INTERVAL " . self::TOKEN_EXPIRY_SECONDS . " SECOND)";
}
/** /**
* Add a new customer reset key * Add a new customer reset key
@@ -65,9 +70,8 @@ class customer_password_reset_keys_o extends db
if (strlen($token) !== self::TOKEN_LENGTH) { if (strlen($token) !== self::TOKEN_LENGTH) {
return null; return null;
} }
// Query the database for a valid token // Query the database for a valid token using the same clock that writes created_at.
$current_time = date('Y-m-d H:i:s'); $sql = "SELECT id FROM $this->table WHERE token = '" . $db->escape_string($token) . "' AND " . $this->validTokenWhereClause() . " LIMIT 1";
$sql = "SELECT id FROM $this->table WHERE token = '" . $db->escape_string($token) . "' AND deleted_at IS NULL AND created_at >= DATE_SUB('$current_time', INTERVAL " . self::TOKEN_EXPIRY_SECONDS . " SECOND) LIMIT 1";
$result = $db->query($sql); $result = $db->query($sql);
if ($result->num_rows === 0) { if ($result->num_rows === 0) {
return null; return null;
@@ -85,13 +89,11 @@ class customer_password_reset_keys_o extends db
*/ */
public function isValidToken(): bool public function isValidToken(): bool
{ {
global $db;
self::requireSelected(); self::requireSelected();
$created_at = strtotime($this->created_at->value()); $sql = "SELECT id FROM $this->table WHERE id = " . (int)$this->id . " AND " . $this->validTokenWhereClause() . " LIMIT 1";
$current_time = time(); $result = $db->query($sql);
return ( return $result->num_rows > 0;
($current_time - $created_at) <= self::TOKEN_EXPIRY_SECONDS) &&
($this->deleted_at->value() === null
);
} }
/** /**
@@ -140,4 +142,4 @@ class customer_password_reset_keys_o extends db
{ {
//TODO: Add cache invalidation //TODO: Add cache invalidation
} }
} }
@@ -7,6 +7,7 @@ use classes\object_property;
use classes\selfserve; use classes\selfserve;
use classes\selfserve_schema_bootstrap; use classes\selfserve_schema_bootstrap;
use Exception; use Exception;
use modules\selfserve\classes\selfserve_config_versioning;
use traits\db_object_t; use traits\db_object_t;
class department_lanes_o extends db class department_lanes_o extends db
@@ -324,9 +325,52 @@ class department_lanes_o extends db
public function getSelfServeLaneProducts(): array public function getSelfServeLaneProducts(): array
{ {
self::requireSelected(); self::requireSelected();
$publishedProducts = $this->getPublishedSelfServeLaneProducts();
if ($publishedProducts !== []) {
return $publishedProducts;
}
return department_selfserve_tasks_o::getLaneProducts((int)$this->id); return department_selfserve_tasks_o::getLaneProducts((int)$this->id);
} }
/**
* @return int[]
*/
private function getPublishedSelfServeLaneProducts(): array
{
try {
$published = (new selfserve_config_versioning())->getPublishedV2Config((int)$this->department->value());
} catch (\Throwable) {
return [];
}
$config = is_array($published['config'] ?? null) ? $published['config'] : null;
if ($config === null) {
return [];
}
$laneId = (int)$this->id;
$products = [];
foreach ((array)($config['tasks'] ?? []) as $task) {
if (!is_array($task)) {
continue;
}
$taskLane = (int)($task['lane'] ?? 0);
if ($taskLane !== 0 && $taskLane !== $laneId) {
continue;
}
$productId = (int)($task['product'] ?? 0);
if ($productId > 0 && !in_array($productId, $products, true)) {
$products[] = $productId;
}
}
sort($products, SORT_NUMERIC);
return $products;
}
/** /**
* @throws Exception * @throws Exception
* @return department_lanes_o[] An array of department lane objects for the specified department * @return department_lanes_o[] An array of department lane objects for the specified department
@@ -369,6 +369,7 @@ class order_bookings_o extends db
return; return;
} }
$this->requireLinkedOrderMatchesBooking($order);
$normalizedSafetySeal = orders_o::normalizeSafetySealValue($safety_seal); $normalizedSafetySeal = orders_o::normalizeSafetySealValue($safety_seal);
if ($normalizedSafetySeal !== null) { if ($normalizedSafetySeal !== null) {
$order->setSafetySealValue($normalizedSafetySeal); $order->setSafetySealValue($normalizedSafetySeal);
@@ -414,11 +415,16 @@ class order_bookings_o extends db
continue; continue;
} }
$orderItems = new order_items_o(); $orderItems = new order_items_o();
$itemNotes = isset($item['notes']) && trim((string)$item['notes']) !== ''
? (string)$item['notes']
: ((string)($this->note->value() ?? '') ?: null);
$orderItems->addItemToOrder( $orderItems->addItemToOrder(
(int)$order->id, (int)$order->id,
(int)$item['id'], (int)$item['id'],
(int)$user_id, (int)$user_id,
(int)$item['quantity'], (int)$item['quantity'],
null,
$itemNotes,
); );
} }
@@ -462,14 +468,34 @@ class order_bookings_o extends db
return $order; return $order;
} }
/**
* @throws Exception
*/
private function requireLinkedOrderMatchesBooking(orders_o $order): void
{
self::requireSelected();
$bookingCustomerNumber = (int)$this->customer_number->value();
$bookingDepartmentId = (int)$this->department->value();
$orderCustomerId = (int)$order->customer_id->value();
$orderDepartmentId = (int)$order->department_id->value();
if ($orderCustomerId !== $bookingCustomerNumber || $orderDepartmentId !== $bookingDepartmentId) {
throw new Exception('Linked order does not match booking customer or department');
}
}
/** /**
* @throws Exception * @throws Exception
*/ */
protected function attachWashCertificate(int $user_id, ?string $safety_seal = null): void protected function attachWashCertificate(int $user_id, ?string $safety_seal = null): void
{ {
self::requireSelected(); self::requireSelected();
$order = $this->getOrder();
$this->requireLinkedOrderMatchesBooking($order);
// Check if the order already has a wash certificate attached // Check if the order already has a wash certificate attached
if ($this->getOrder()->hasWashCertificateAttached()) { if ($order->hasWashCertificateAttached()) {
return; return;
} }
// Get the operator name // Get the operator name
+11 -3
View File
@@ -270,6 +270,8 @@ class orders_o extends db
public function getOrderHistoryByVehiclePlate(string $plate, int $entries = 10): array public function getOrderHistoryByVehiclePlate(string $plate, int $entries = 10): array
{ {
global $db; global $db;
$plate = $db->escape_string($plate);
$entries = max(1, $entries);
$sql = "SELECT * FROM $this->table WHERE reg_1 = '$plate' OR reg_2 = '$plate' OR reg_3 = '$plate' ORDER BY id DESC LIMIT $entries"; $sql = "SELECT * FROM $this->table WHERE reg_1 = '$plate' OR reg_2 = '$plate' OR reg_3 = '$plate' ORDER BY id DESC LIMIT $entries";
$result = $db->query($sql); $result = $db->query($sql);
return $db->fetch_all($result); return $db->fetch_all($result);
@@ -537,6 +539,7 @@ class orders_o extends db
{ {
global /** @var db $db */ global /** @var db $db */
$db; $db;
$plate = $db->escape_string($plate);
$sql = "SELECT id FROM $this->table WHERE reg_1 = '$plate' OR reg_2 = '$plate' OR reg_3 = '$plate' AND deleted_at IS NULL ORDER BY id DESC LIMIT 5"; $sql = "SELECT id FROM $this->table WHERE reg_1 = '$plate' OR reg_2 = '$plate' OR reg_3 = '$plate' AND deleted_at IS NULL ORDER BY id DESC LIMIT 5";
$result = $db->query($sql); $result = $db->query($sql);
$orders = $db->fetch_all($result); $orders = $db->fetch_all($result);
@@ -590,8 +593,14 @@ class orders_o extends db
public function get_vehicle_order_history(string $plate): array public function get_vehicle_order_history(string $plate): array
{ {
global $db; global $db;
$sql = "SELECT * FROM $this->table WHERE reg_1 = '$plate' OR reg_2 = '$plate' OR reg_3 = '$plate' AND deleted_at IS NULL ORDER BY id DESC LIMIT 5"; $stmt = $db->prepare("SELECT * FROM $this->table WHERE (reg_1 = ? OR reg_2 = ? OR reg_3 = ?) AND deleted_at IS NULL ORDER BY id DESC LIMIT 5");
$result = $db->query($sql); if (!$stmt) {
return [];
}
$stmt->bind_param('sss', $plate, $plate, $plate);
$stmt->execute();
$result = $stmt->get_result();
$stmt->close();
return $db->fetch_all($result); return $db->fetch_all($result);
} }
@@ -2155,7 +2164,6 @@ class orders_o extends db
} }
return $orders; return $orders;
} }
/** /**
* @throws Exception * @throws Exception
*/ */
@@ -158,6 +158,18 @@ class selfserve_wash_sessions_o extends db
} }
public function markCompleted(?int $orderId = null): void public function markCompleted(?int $orderId = null): void
{
if (!$this->closeIfOpen(selfserve_wash_session_status::COMPLETED, $orderId)) {
return;
}
}
public function markCompletedIfOpen(?int $orderId = null): bool
{
return $this->closeIfOpen(selfserve_wash_session_status::COMPLETED, $orderId);
}
private function markCompletedInMemory(?int $orderId = null): void
{ {
$this->completed_at->set(date('Y-m-d H:i:s')); $this->completed_at->set(date('Y-m-d H:i:s'));
if ($orderId !== null) { if ($orderId !== null) {
@@ -167,6 +179,18 @@ class selfserve_wash_sessions_o extends db
} }
public function markForceStopped(?int $orderId = null, ?array $metadata = null): void public function markForceStopped(?int $orderId = null, ?array $metadata = null): void
{
if (!$this->closeIfOpen(selfserve_wash_session_status::FORCE_STOPPED, $orderId, $metadata)) {
return;
}
}
public function markForceStoppedIfOpen(?int $orderId = null, ?array $metadata = null): bool
{
return $this->closeIfOpen(selfserve_wash_session_status::FORCE_STOPPED, $orderId, $metadata);
}
private function markForceStoppedInMemory(?int $orderId = null, ?array $metadata = null): void
{ {
$this->completed_at->set(date('Y-m-d H:i:s')); $this->completed_at->set(date('Y-m-d H:i:s'));
if ($orderId !== null) { if ($orderId !== null) {
@@ -181,6 +205,53 @@ class selfserve_wash_sessions_o extends db
$this->status->set(selfserve_wash_session_status::FORCE_STOPPED->value); $this->status->set(selfserve_wash_session_status::FORCE_STOPPED->value);
} }
private function closeIfOpen(selfserve_wash_session_status $status, ?int $orderId = null, ?array $metadata = null): bool
{
if (!$this->isPersistedSession()) {
if ($status === selfserve_wash_session_status::COMPLETED) {
$this->markCompletedInMemory($orderId);
} else {
$this->markForceStoppedInMemory($orderId, $metadata);
}
return true;
}
global $db;
$updates = [
"`completed_at` = NOW()",
"`status` = '" . $db->escape_string($status->value) . "'",
];
if ($orderId !== null) {
$updates[] = "`order_id` = " . (int)$orderId;
}
if ($metadata !== null) {
$existing = $this->metadata_json->value();
$existing = is_array($existing) ? $existing : [];
$existing['force_stop'] = $metadata;
$updates[] = "`metadata_json` = '" . $db->escape_string(json_encode($existing, JSON_THROW_ON_ERROR)) . "'";
}
$terminalStatuses = self::terminalStatusSqlList();
$db->query(
"UPDATE `selfserve_wash_sessions` SET " . implode(', ', $updates) .
" WHERE `id` = " . (int)$this->id .
" AND `completed_at` IS NULL" .
" AND UPPER(TRIM(`status`)) NOT IN ($terminalStatuses)"
);
$changed = $db->conn()->affected_rows > 0;
$this->select((int)$this->id);
return $changed;
}
private function isPersistedSession(): bool
{
return isset($this->id) && (int)$this->id > 0 && $this->exists();
}
public function selectLatestOpenByLane(int $laneId, ?int $customerNumber = null): self public function selectLatestOpenByLane(int $laneId, ?int $customerNumber = null): self
{ {
$filters = [ $filters = [
-19
View File
@@ -320,9 +320,6 @@ class subusers_o extends db
$session_token = bin2hex(random_bytes(32)); $session_token = bin2hex(random_bytes(32));
$this->cache('session_token:' . $session_token, $this->id, 'subuser_sessions'); $this->cache('session_token:' . $session_token, $this->id, 'subuser_sessions');
$this->setCachedExpiration('session_token:' . $session_token, 7 * 24 * 60 * 60, 'subuser_sessions'); // Set the session to expire after 7 days $this->setCachedExpiration('session_token:' . $session_token, 7 * 24 * 60 * 60, 'subuser_sessions'); // Set the session to expire after 7 days
// Add the token
$tokens_o = new tokens_o();
$tokens_o->create($this->id, $session_token, 'AUTH_TOKEN_SUBUSER');
return $session_token; return $session_token;
} }
@@ -346,22 +343,6 @@ class subusers_o extends db
$subuser->getObjectProperties(); $subuser->getObjectProperties();
return $subuser; return $subuser;
} }
// Fallback: resolve via tokens table if cache is missing/expired
try {
// tokens_o::getToken() might throw Exception if not found
$tok = (new tokens_o())->getToken($token);
if ($tok && $tok->id && $tok->type->value() === 'AUTH_TOKEN_SUBUSER') {
$resolvedId = (int)$tok->user_id->value();
// Re-cache mapping for future lookups (7 days to match session lifetime)
$this->cache($cache_key, $resolvedId, $cache_object_id);
$this->setCachedExpiration($cache_key, 7 * 24 * 60 * 60, $cache_object_id);
$subuser = (new subusers_o())->select($resolvedId);
$subuser->getObjectProperties();
return $subuser;
}
} catch (Exception $e) {
// Token not found or other error; treat as missing
}
return null; return null;
} }
+96 -33
View File
@@ -18,6 +18,8 @@ class users_o extends db
{ {
use db_object_t; use db_object_t;
public const KEY_SUPERUSER_NEW_CUSTOMER_EMAIL_NOTIFICATIONS = 'superuser_new_customer_email_notifications_enabled';
public object_property $customer_number; public object_property $customer_number;
public object_property $display_name; public object_property $display_name;
public object_property $group_id; public object_property $group_id;
@@ -125,40 +127,47 @@ class users_o extends db
private function importCustomerFromExternalSource(int $customer_number): object|bool private function importCustomerFromExternalSource(int $customer_number): object|bool
{ {
global $db;
// Get the customer data from the external source // Get the customer data from the external source
$economic = new economicCustomers(); $economic = new economicCustomers();
$customer_data = $economic->getCustomerId($customer_number); $customer_data = $economic->getCustomerId($customer_number);
// DEBUG: Return the customer data
// Check if the customer exists
if ($customer_data) { if ($customer_data) {
// Avoid SQL injection return $this->importCustomerFromEconomicCustomerData($customer_data);
$customer_number = $db->escape_string($customer_data->customerNumber);
// Double check if the customer exists
$sql = "SELECT * FROM $this->table WHERE customer_number = '$customer_number'";
$result = $db->query($sql);
if ($result->num_rows > 0) {
$this->id = $result->fetch_assoc()['id'];
$this->getObjectProperties();
} else {
// Import the customer
$this->add($customer_number, '', 0);
// Nullify the password
$this->password->nullify();
// If the customer has an email address, save it
if (isset($customer_data->email)) {
$this->email->set($customer_data->email);
}
// If the customer has a name, save it as the display name
if (isset($customer_data->name)) {
$this->display_name->set($customer_data->name);
}
}
} }
// Else return false
return false; return false;
} }
public function importCustomerFromEconomicCustomerData(object $customer_data): users_o|bool
{
global $db;
if (!isset($customer_data->customerNumber) || !is_numeric($customer_data->customerNumber)) {
return false;
}
$customer_number = $db->escape_string((string)$customer_data->customerNumber);
$sql = "SELECT * FROM $this->table WHERE customer_number = '$customer_number'";
$result = $db->query($sql);
if ($result->num_rows > 0) {
$this->id = (int)$result->fetch_assoc()['id'];
$this->getObjectProperties();
return $this;
}
$this->add($customer_number, '', 0);
$this->password->nullify();
if (isset($customer_data->email)) {
$this->email->set($customer_data->email);
}
if (isset($customer_data->name)) {
$this->display_name->set($customer_data->name);
}
return $this;
}
/** /**
* @throws Exception * @throws Exception
*/ */
@@ -256,7 +265,7 @@ class users_o extends db
* @param int|null $user_id The user id to add the attribute to * @param int|null $user_id The user id to add the attribute to
* @throws Exception If the user is not selected, and the user_id is null * @throws Exception If the user is not selected, and the user_id is null
*/ */
public function addAttribute(string $attribute, int $user_id = null): void public function addAttribute(string $attribute, ?int $user_id = null): void
{ {
global $db; global $db;
if ($user_id === null) { if ($user_id === null) {
@@ -270,7 +279,7 @@ class users_o extends db
$db->query($sql); $db->query($sql);
} }
public function deleteAttribute(string $attribute, int $user_id = null): void public function deleteAttribute(string $attribute, ?int $user_id = null): void
{ {
global $db; global $db;
if ($user_id === null) { if ($user_id === null) {
@@ -302,7 +311,7 @@ class users_o extends db
$db->query($sql); $db->query($sql);
} }
public function doesUserHaveAttribute(string $attribute, int $user_id = null): bool public function doesUserHaveAttribute(string $attribute, ?int $user_id = null): bool
{ {
global $db; global $db;
if ($user_id === null) { if ($user_id === null) {
@@ -436,6 +445,7 @@ class users_o extends db
'sms_notifications_enabled' => (bool)$this->sms_notifications_enabled->value(), 'sms_notifications_enabled' => (bool)$this->sms_notifications_enabled->value(),
'email_notifications_enabled' => (bool)$this->email_notifications_enabled->value(), 'email_notifications_enabled' => (bool)$this->email_notifications_enabled->value(),
'wash_certificate_email' => $this->wash_certificate_email->value(), 'wash_certificate_email' => $this->wash_certificate_email->value(),
self::KEY_SUPERUSER_NEW_CUSTOMER_EMAIL_NOTIFICATIONS => $this->isSuperuserNewCustomerEmailNotificationsEnabled(),
], ],
'created_at' => $this->created_at->value(), 'created_at' => $this->created_at->value(),
'updated_at' => $this->updated_at->value(), 'updated_at' => $this->updated_at->value(),
@@ -515,8 +525,12 @@ class users_o extends db
return customer_name_cache_payload_builder::build($cached_name, $fallback_name); return customer_name_cache_payload_builder::build($cached_name, $fallback_name);
} }
public function getCustomerEcocomicData(int $customer_number = null): users_o public function getCustomerEcocomicData(?int $customer_number = null): users_o
{ {
if ($customer_number !== null && !isset($this->id)) {
$this->getUserByCustomerNumber($customer_number);
}
// Check if the customer number is set // Check if the customer number is set
if (!isset($this->customer_number) && $customer_number === null) { if (!isset($this->customer_number) && $customer_number === null) {
return $this; return $this;
@@ -528,7 +542,9 @@ class users_o extends db
return $this; return $this;
} }
$cachedCustomer = $this->getCached('economic_customer'); $cachedCustomer = isset($this->id) && $this->id > 0
? $this->getCached('economic_customer')
: null;
if (is_object($cachedCustomer)) { if (is_object($cachedCustomer)) {
$cachedCustomerNumber = (int)($cachedCustomer->customerNumber ?? $cachedCustomer->customer_number ?? 0); $cachedCustomerNumber = (int)($cachedCustomer->customerNumber ?? $cachedCustomer->customer_number ?? 0);
if ($cachedCustomerNumber === $customer_number) { if ($cachedCustomerNumber === $customer_number) {
@@ -714,7 +730,7 @@ class users_o extends db
$this->permissions = $perms; $this->permissions = $perms;
} }
public function getUserAttributes(int $user_id = null): array public function getUserAttributes(?int $user_id = null): array
{ {
global $db; global $db;
if ($user_id === null) { if ($user_id === null) {
@@ -831,6 +847,53 @@ class users_o extends db
return false; return false;
} }
public function isSuperuserNewCustomerEmailNotificationsEnabled(): bool
{
self::requireSelected();
$value = $this->keys->setUser($this->id)->getValue(self::KEY_SUPERUSER_NEW_CUSTOMER_EMAIL_NOTIFICATIONS);
return in_array(strtolower((string)$value), ['1', 'true', 'yes', 'on'], true);
}
public function setSuperuserNewCustomerEmailNotificationsEnabled(bool $enabled): void
{
self::requireSelected();
$this->keys->setUser($this->id)->setValue(
self::KEY_SUPERUSER_NEW_CUSTOMER_EMAIL_NOTIFICATIONS,
$enabled ? '1' : '0'
);
}
/**
* @return array<int, array{id:int, customer_number:int, display_name:string|null, email:string}>
*/
public function getSuperuserNewCustomerEmailNotificationRecipients(): array
{
global $db;
$key = $db->escape_string(self::KEY_SUPERUSER_NEW_CUSTOMER_EMAIL_NOTIFICATIONS);
$sql = "
SELECT DISTINCT
u.id,
u.customer_number,
u.display_name,
u.email
FROM users u
INNER JOIN user_key_value_pairs kv
ON kv.user_id = u.id
AND kv.var = '$key'
AND LOWER(kv.val) IN ('1', 'true', 'yes', 'on')
LEFT JOIN groups_permissions gp
ON gp.group_id = u.group_id
AND gp.permission = 'superuser'
WHERE u.deleted_at IS NULL
AND u.email IS NOT NULL
AND u.email <> ''
AND (u.group_id = 1 OR gp.id IS NOT NULL)
";
return $db->fetch_all($db->query($sql));
}
public function setOpenInvoiceDraft(int $draftInvoiceNumber): void public function setOpenInvoiceDraft(int $draftInvoiceNumber): void
{ {
// Set the open invoice draft (key = 'open_invoice_draft') // Set the open invoice draft (key = 'open_invoice_draft')
@@ -1056,7 +1119,7 @@ class users_o extends db
* Set the password for the user * Set the password for the user
* @throws Exception If the user is not selected * @throws Exception If the user is not selected
*/ */
public function setPassword(string $password = null): void public function setPassword(?string $password = null): void
{ {
self::requireSelected(); self::requireSelected();
global $db; global $db;
+192 -6
View File
@@ -3043,6 +3043,7 @@ paths:
wash_certificate_email: {type: string} wash_certificate_email: {type: string}
sms_notifications_enabled: {type: boolean} sms_notifications_enabled: {type: boolean}
email_notifications_enabled: {type: boolean} email_notifications_enabled: {type: boolean}
superuser_new_customer_email_notifications_enabled: {type: boolean}
responses: responses:
'200': '200':
description: Success description: Success
@@ -6239,6 +6240,59 @@ paths:
'200': '200':
description: Success description: Success
/order-bookings/booking-confirmation/resend:
post:
tags:
- Bookings
summary: Resend order booking confirmation
description: Resends the customer booking confirmation email for an order booking. Requires `resend_booking_confirmations` and access to the booking's department.
operationId: resendOrderBookingConfirmation
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [id]
properties:
id: {type: integer}
responses:
'200':
description: Booking confirmation resent successfully
content:
application/json:
schema: {}
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/order-bookings/completion-confirmation/resend:
post:
tags:
- Bookings
summary: Resend order booking completion confirmation
description: Resends the customer completion confirmation email with the wash certificate for a completed order booking. Requires `complete_bookings` and access to the booking's department.
operationId: resendOrderBookingCompletionConfirmation
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [id]
properties:
id: {type: integer}
responses:
'200':
description: Completion confirmation resent successfully
content:
application/json:
schema: {}
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'409': { $ref: '#/components/responses/Conflict' }
/order-bookings/complete: /order-bookings/complete:
post: post:
tags: tags:
@@ -9208,6 +9262,47 @@ paths:
'403': '403':
$ref: '#/components/responses/Forbidden' $ref: '#/components/responses/Forbidden'
/modules/self-serve/lane/wash/my-active-wash:
get:
tags:
- Modules
summary: Get the authenticated customer's active self-serve wash
description: |
Returns the latest active self-serve wash for the authenticated customer,
without requiring the frontend to know or poll a lane id.
operationId: getMyActiveSelfServeWash
responses:
'200':
description: Active self-serve wash details resolved
content:
application/json:
schema:
type: object
properties:
lane_id:
type: integer
status:
type: string
in_progress:
type: boolean
elapsed_minutes:
type: integer
session:
type: object
nullable: true
customer:
type: object
nullable: true
vehicle:
type: object
nullable: true
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/modules/self-serve/sessions: /modules/self-serve/sessions:
get: get:
tags: tags:
@@ -9330,10 +9425,10 @@ paths:
description: | description: |
Send a command (e.g., start, stop, reset) to a self-serve lane. Send a command (e.g., start, stop, reset) to a self-serve lane.
Property gate commands (`OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`) are also supported here. Property gate commands (`OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`) are also supported here.
Property gate commands require the matching explicit command permissions.
Operator callers require the base command permission plus the command-specific permission. Authenticated Operator callers require the base command permission plus the command-specific permission. Authenticated
customers with `list_own_department_selfserve_vehicle_conditions` may send `START` on enabled self-serve customers with `list_own_department_selfserve_vehicle_conditions` may send `START` on enabled self-serve
lanes. Customer `STOP` and property gate commands require the customer's active self-serve wash in the target lanes. Customer `STOP` requires the customer's active self-serve wash in the target department.
department.
operationId: sendSelfServeLaneCommand operationId: sendSelfServeLaneCommand
requestBody: requestBody:
required: true required: true
@@ -9353,6 +9448,14 @@ paths:
license_plate: license_plate:
type: string type: string
description: Required for START command description: Required for START command
wash_type:
type: string
enum: [Manual, Machine]
description: Optional customer-selected wash type for START. When provided, Manual and Machine start actions use this explicit choice instead of inferring mode from allowed services.
wash_mode:
type: string
enum: [manual, machine]
description: Lowercase alias for wash_type accepted by backend clients.
customer_number: customer_number:
type: integer type: integer
description: Required for START and RESERVE commands. The authenticated customer's number is applied server-side when omitted by user clients. description: Required for START and RESERVE commands. The authenticated customer's number is applied server-side when omitted by user clients.
@@ -11533,6 +11636,52 @@ paths:
schema: schema:
$ref: '#/components/schemas/ModuleConfigTestResponse' $ref: '#/components/schemas/ModuleConfigTestResponse'
/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'
/slack/config/test:
post:
tags: [Config]
summary: Test Slack customer registration webhook
operationId: testSlackCustomerRegistrationWebhook
responses:
'200':
description: Slack customer registration webhook test completed successfully
content:
application/json:
schema:
$ref: '#/components/schemas/SlackConfigTestResponse'
'400':
description: Slack customer registration webhook URL is not configured
'502':
description: Slack customer registration webhook test failed
/backups/config: /backups/config:
get: get:
tags: [Config] tags: [Config]
@@ -15047,6 +15196,25 @@ components:
- type: integer - type: integer
required: [module, variable, type, value] required: [module, variable, type, 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]
SlackConfigTestResult:
type: object
properties:
configured: { type: boolean }
sent: { type: boolean }
message: { type: string }
required: [configured, sent, message]
BackupsConfigEntry: BackupsConfigEntry:
type: object type: object
properties: properties:
@@ -15315,6 +15483,22 @@ components:
data: { type: array, items: { $ref: '#/components/schemas/EmailConfigEntry' } } data: { type: array, items: { $ref: '#/components/schemas/EmailConfigEntry' } }
required: [data] required: [data]
SlackConfigListResponse:
allOf:
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
- type: object
properties:
data: { type: array, items: { $ref: '#/components/schemas/SlackConfigEntry' } }
required: [data]
SlackConfigTestResponse:
allOf:
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
- type: object
properties:
data: { $ref: '#/components/schemas/SlackConfigTestResult' }
required: [data]
BackupsConfigListResponse: BackupsConfigListResponse:
allOf: allOf:
- $ref: '#/components/schemas/ModuleConfigEnvelopeBase' - $ref: '#/components/schemas/ModuleConfigEnvelopeBase'
@@ -18283,13 +18467,17 @@ components:
max_states: max_states:
type: integer type: integer
minimum: 1 minimum: 1
maximum: 2048
default: 2048
nullable: true nullable: true
description: Optional debug cap. Omit for complete path projection. description: Optional debug cap for explored states. Omitted and larger values are capped at 2048.
path_sample_limit: path_sample_limit:
type: integer type: integer
minimum: 1 minimum: 1
maximum: 2048
default: 2048
nullable: true nullable: true
description: Optional debug cap for returned path rows. Omit to return every terminal path row. description: Optional cap for returned path rows. Omitted returns every projected terminal path within the state cap; larger values are capped at 2048.
SelfserveStudioPathOutcomesResponse: SelfserveStudioPathOutcomesResponse:
type: object type: object
@@ -21322,5 +21510,3 @@ components:
success: { type: boolean, example: true } success: { type: boolean, example: true }
data: data:
$ref: '#/components/schemas/DepartmentDailyReportOutsideHoursTrendPayload' $ref: '#/components/schemas/DepartmentDailyReportOutsideHoursTrendPayload'
+25
View File
@@ -0,0 +1,25 @@
parameters:
level: 0
paths:
- classes
- interfaces
- traits
- objects
- modules
- routes
- statistics
- tests/Unit
- tests/Integration
- tests/Api
bootstrapFiles:
- vendor/autoload.php
tmpDir: build/phpstan
excludePaths:
analyse:
- vendor
- build
- .phpunit.cache
- modules/*/vendor
- modules/*/vendor/*
- tests/Legacy
reportUnmatchedIgnoredErrors: false
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use Rector\Config\RectorConfig;
return RectorConfig::configure()
->withPaths([
__DIR__ . '/classes',
__DIR__ . '/interfaces',
__DIR__ . '/traits',
__DIR__ . '/objects',
__DIR__ . '/modules',
__DIR__ . '/routes',
__DIR__ . '/statistics',
__DIR__ . '/tests/Unit',
__DIR__ . '/tests/Integration',
__DIR__ . '/tests/Api',
])
->withBootstrapFiles([
__DIR__ . '/vendor/autoload.php',
])
->withSkip([
__DIR__ . '/build',
__DIR__ . '/vendor',
__DIR__ . '/.phpunit.cache',
__DIR__ . '/modules/*/vendor',
__DIR__ . '/modules/*/vendor/*',
__DIR__ . '/tests/Legacy',
])
->withPreparedSets(
codeQuality: true,
codingStyle: true,
phpunitCodeQuality: true,
);
@@ -3,9 +3,10 @@ FROM ${BASE_IMAGE}
RUN set -eux; \ RUN set -eux; \
apt-get update; \ apt-get update; \
apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose; \ apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose libcurl4-openssl-dev libsqlite3-dev; \
rm -rf /var/lib/apt/lists/*; \ docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite; \
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }' php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \
rm -rf /var/lib/apt/lists/*
COPY auto-updater.php /usr/local/bin/auto-updater.php COPY auto-updater.php /usr/local/bin/auto-updater.php
@@ -2,7 +2,11 @@ ARG BASE_IMAGE=php:8.2-cli-bookworm
FROM ${BASE_IMAGE} FROM ${BASE_IMAGE}
RUN set -eux; \ RUN set -eux; \
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }' apt-get update; \
apt-get install -y --no-install-recommends libcurl4-openssl-dev libsqlite3-dev; \
docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite; \
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \
rm -rf /var/lib/apt/lists/*
WORKDIR /opt/truckwash-edge-agent WORKDIR /opt/truckwash-edge-agent
@@ -2,7 +2,11 @@ ARG BASE_IMAGE=php:8.2-cli-bookworm
FROM ${BASE_IMAGE} FROM ${BASE_IMAGE}
RUN set -eux; \ RUN set -eux; \
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }' apt-get update; \
apt-get install -y --no-install-recommends libcurl4-openssl-dev libsqlite3-dev; \
docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite; \
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \
rm -rf /var/lib/apt/lists/*
WORKDIR /opt/truckwash-edge-agent WORKDIR /opt/truckwash-edge-agent
@@ -61,7 +61,7 @@ final class OperationAbortException extends RuntimeException
final class HttpJsonClient final class HttpJsonClient
{ {
public function __construct(private readonly string $baseUrl) public function __construct(private readonly string $baseUrl, private readonly array $defaultHeaders = [])
{ {
} }
@@ -91,7 +91,7 @@ final class HttpJsonClient
private function requestJson(string $method, string $url, ?array $payload, int $timeoutSeconds): array private function requestJson(string $method, string $url, ?array $payload, int $timeoutSeconds): array
{ {
$headers = ['Accept: application/json']; $headers = array_values(array_merge(['Accept: application/json'], $this->defaultHeaders));
if ($payload !== null) { if ($payload !== null) {
$headers[] = 'Content-Type: application/json'; $headers[] = 'Content-Type: application/json';
} }
@@ -1031,7 +1031,10 @@ final class TruckwashEdgeAgent
} }
$this->http = new HttpJsonClient((string)$this->config->get('apiUrl')); $this->http = new HttpJsonClient((string)$this->config->get('apiUrl'));
$this->workerHttp = new HttpJsonClient((string)$this->config->get('workerBaseUrl', self::DEFAULT_WORKER_BASE_URL)); $this->workerHttp = new HttpJsonClient(
(string)$this->config->get('workerBaseUrl', self::DEFAULT_WORKER_BASE_URL),
$this->workerAuthorizationHeaders()
);
$this->logger = new Logger($this->runtimeDir . DIRECTORY_SEPARATOR . 'agent.log'); $this->logger = new Logger($this->runtimeDir . DIRECTORY_SEPARATOR . 'agent.log');
$this->stateStore = new LocalStateStore((string)$this->config->get('stateDatabasePath', self::DEFAULT_STATE_DATABASE)); $this->stateStore = new LocalStateStore((string)$this->config->get('stateDatabasePath', self::DEFAULT_STATE_DATABASE));
$this->statePath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'current-operation.json'; $this->statePath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'current-operation.json';
@@ -2615,6 +2618,12 @@ final class TruckwashEdgeAgent
} }
} }
private function workerAuthorizationHeaders(): array
{
$agentToken = trim((string)$this->config->get('agentToken', ''));
return $agentToken !== '' ? ['X-Truckwash-Worker-Token: ' . $agentToken] : [];
}
private function ensureAgentInstanceId(): string private function ensureAgentInstanceId(): string
{ {
$configured = trim((string)$this->config->get('agentInstanceId', '')); $configured = trim((string)$this->config->get('agentInstanceId', ''));
@@ -5,11 +5,13 @@ services:
image: ${REDIS_BASE_IMAGE:-redis:7-alpine} image: ${REDIS_BASE_IMAGE:-redis:7-alpine}
container_name: truckwash-redis container_name: truckwash-redis
restart: unless-stopped restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"] command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:?set REDIS_PASSWORD}"]
environment:
REDIS_PASSWORD: "${REDIS_PASSWORD:?set REDIS_PASSWORD}"
volumes: volumes:
- ./runtime/redis:/data - ./runtime/redis:/data
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "ping"] test: ["CMD-SHELL", 'redis-cli -a "$$REDIS_PASSWORD" ping | grep -q PONG']
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -19,10 +21,10 @@ services:
container_name: truckwash-mariadb container_name: truckwash-mariadb
restart: unless-stopped restart: unless-stopped
environment: environment:
MARIADB_DATABASE: truckwash_edge MARIADB_DATABASE: ${MARIADB_DATABASE:-truckwash_edge}
MARIADB_USER: truckwash_edge MARIADB_USER: ${MARIADB_USER:-truckwash_edge}
MARIADB_PASSWORD: truckwash_edge MARIADB_PASSWORD: "${MARIADB_PASSWORD:?set MARIADB_PASSWORD}"
MARIADB_ROOT_PASSWORD: truckwash_edge_root MARIADB_ROOT_PASSWORD: "${MARIADB_ROOT_PASSWORD:?set MARIADB_ROOT_PASSWORD}"
volumes: volumes:
- ./runtime/mariadb:/var/lib/mysql - ./runtime/mariadb:/var/lib/mysql
@@ -32,8 +34,8 @@ services:
restart: unless-stopped restart: unless-stopped
command: server /data --console-address ":9001" command: server /data --console-address ":9001"
environment: environment:
MINIO_ROOT_USER: truckwashminio MINIO_ROOT_USER: "${MINIO_ROOT_USER:?set MINIO_ROOT_USER}"
MINIO_ROOT_PASSWORD: truckwash_edge_storage MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD}"
volumes: volumes:
- ./runtime/minio:/data - ./runtime/minio:/data
@@ -55,6 +57,7 @@ services:
minio: minio:
condition: service_started condition: service_started
volumes: volumes:
- ./config.json:/config/config.json:ro
- ./runtime:/opt/truckwash-edge-agent/runtime - ./runtime:/opt/truckwash-edge-agent/runtime
healthcheck: healthcheck:
test: test:
@@ -5,6 +5,7 @@ ACTION="${1:-up}"
INSTALL_DIR="${TRUCKWASH_INSTALL_DIR:-/opt/truckwash-edge-agent}" INSTALL_DIR="${TRUCKWASH_INSTALL_DIR:-/opt/truckwash-edge-agent}"
CONFIG_PATH="$INSTALL_DIR/config.json" CONFIG_PATH="$INSTALL_DIR/config.json"
COMPOSE_FILE="$INSTALL_DIR/docker-compose.gateway.yml" COMPOSE_FILE="$INSTALL_DIR/docker-compose.gateway.yml"
ENV_FILE="$INSTALL_DIR/.env"
RUNTIME_DIR="$INSTALL_DIR/runtime" RUNTIME_DIR="$INSTALL_DIR/runtime"
ROLLBACK_STATUS_PATH="$RUNTIME_DIR/rollback-status.json" ROLLBACK_STATUS_PATH="$RUNTIME_DIR/rollback-status.json"
STAGED_UPDATE_PATH="$RUNTIME_DIR/staged-update.json" STAGED_UPDATE_PATH="$RUNTIME_DIR/staged-update.json"
@@ -64,6 +65,42 @@ ensure_dirs() {
mkdir -p "$RUNTIME_DIR" "$RUNTIME_DIR/backups" mkdir -p "$RUNTIME_DIR" "$RUNTIME_DIR/backups"
} }
ensure_stack_env() {
php -r '
$path = $argv[1];
$content = is_file($path) ? (string)file_get_contents($path) : "";
$hasValue = static function (string $name) use ($content): bool {
if (!preg_match("/^" . preg_quote($name, "/") . "=(.*)$/m", $content, $matches)) {
return false;
}
return trim((string)$matches[1], " \t\"") !== "";
};
$secret = static function (int $bytes): string {
return rtrim(strtr(base64_encode(random_bytes($bytes)), "+/", "-_"), "=");
};
$generated = [];
$required = [
"REDIS_PASSWORD" => static fn(): string => $secret(32),
"MARIADB_PASSWORD" => static fn(): string => $secret(32),
"MARIADB_ROOT_PASSWORD" => static fn(): string => $secret(32),
"MINIO_ROOT_USER" => static fn(): string => "twminio" . bin2hex(random_bytes(12)),
"MINIO_ROOT_PASSWORD" => static fn(): string => $secret(32),
];
foreach ($required as $name => $factory) {
if (!$hasValue($name)) {
$generated[] = $name . "=" . $factory();
}
}
if ($generated === []) {
exit(0);
}
$prefix = ($content !== "" && !str_ends_with($content, "\n")) ? "\n" : "";
file_put_contents($path, $prefix . implode("\n", $generated) . "\n", FILE_APPEND | LOCK_EX);
' "$ENV_FILE"
chmod 0600 "$ENV_FILE"
}
within_update_window() { within_update_window() {
local window local window
window="$(config_value updateWindow '02:00-04:00')" window="$(config_value updateWindow '02:00-04:00')"
@@ -118,6 +155,7 @@ apply_stack() {
mariadb_base_image="$(config_value mariadbBaseImage 'mariadb:11')" mariadb_base_image="$(config_value mariadbBaseImage 'mariadb:11')"
minio_base_image="$(config_value minioBaseImage 'minio/minio:latest')" minio_base_image="$(config_value minioBaseImage 'minio/minio:latest')"
compose_project_name="$(config_value composeProjectName 'truckwash-edge-gateway')" compose_project_name="$(config_value composeProjectName 'truckwash-edge-gateway')"
ensure_stack_env
cd "$INSTALL_DIR" cd "$INSTALL_DIR"
COMPOSE_PROJECT_NAME="$compose_project_name" \ COMPOSE_PROJECT_NAME="$compose_project_name" \
EDGE_AGENT_BASE_IMAGE="$edge_base_image" \ EDGE_AGENT_BASE_IMAGE="$edge_base_image" \
@@ -20,6 +20,65 @@ function worker_read_json_body(): array
return is_array($decoded) ? $decoded : []; return is_array($decoded) ? $decoded : [];
} }
function worker_config_path(): string
{
$configuredPath = trim((string)getenv('TRUCKWASH_WORKER_CONFIG_PATH'));
return $configuredPath !== '' ? $configuredPath : '/config/config.json';
}
function worker_expected_token(): string
{
$environmentToken = trim((string)getenv('TRUCKWASH_WORKER_TOKEN'));
if ($environmentToken !== '') {
return $environmentToken;
}
$configPath = worker_config_path();
if (!is_file($configPath)) {
return '';
}
$decoded = json_decode((string)file_get_contents($configPath), true);
return is_array($decoded) ? trim((string)($decoded['agentToken'] ?? '')) : '';
}
function worker_request_token(): string
{
$headerToken = trim((string)($_SERVER['HTTP_X_TRUCKWASH_WORKER_TOKEN'] ?? ''));
if ($headerToken !== '') {
return $headerToken;
}
$authorization = trim((string)($_SERVER['HTTP_AUTHORIZATION'] ?? ''));
if (str_starts_with(strtolower($authorization), 'bearer ')) {
return trim(substr($authorization, 7));
}
return '';
}
function worker_require_authorization(): bool
{
$expectedToken = worker_expected_token();
if ($expectedToken === '') {
worker_json_response(503, [
'message' => 'LAN worker authorization is not configured',
'error_code' => 'EDGE_GATEWAY_WORKER_AUTH_UNCONFIGURED',
]);
return false;
}
if (!hash_equals($expectedToken, worker_request_token())) {
worker_json_response(401, [
'message' => 'Unauthorized',
'error_code' => 'EDGE_GATEWAY_WORKER_UNAUTHORIZED',
]);
return false;
}
return true;
}
function worker_http_get_json(string $url, int $timeoutSeconds = 8): array function worker_http_get_json(string $url, int $timeoutSeconds = 8): array
{ {
$ch = curl_init($url); $ch = curl_init($url);
@@ -124,6 +183,10 @@ try {
} }
if ($method === 'POST' && $path === '/discover') { if ($method === 'POST' && $path === '/discover') {
if (!worker_require_authorization()) {
return;
}
worker_json_response(200, [ worker_json_response(200, [
'inventory' => [[ 'inventory' => [[
'device_id' => 'gateway-runtime-' . substr(sha1($hostname), 0, 10), 'device_id' => 'gateway-runtime-' . substr(sha1($hostname), 0, 10),
@@ -147,6 +210,10 @@ try {
} }
if ($method === 'POST' && $path === '/relay/status') { if ($method === 'POST' && $path === '/relay/status') {
if (!worker_require_authorization()) {
return;
}
$localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? '')); $localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? ''));
$channel = (int)($body['channel'] ?? 0); $channel = (int)($body['channel'] ?? 0);
$includeInput = (bool)($body['include_input'] ?? $body['includeInput'] ?? false); $includeInput = (bool)($body['include_input'] ?? $body['includeInput'] ?? false);
@@ -155,6 +222,10 @@ try {
} }
if ($method === 'POST' && $path === '/relay/input-status') { if ($method === 'POST' && $path === '/relay/input-status') {
if (!worker_require_authorization()) {
return;
}
$localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? '')); $localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? ''));
$channel = (int)($body['channel'] ?? 0); $channel = (int)($body['channel'] ?? 0);
$input = worker_fetch_shelly_input_state($localIp, $channel); $input = worker_fetch_shelly_input_state($localIp, $channel);
@@ -166,6 +237,10 @@ try {
} }
if ($method === 'POST' && $path === '/relay/switch') { if ($method === 'POST' && $path === '/relay/switch') {
if (!worker_require_authorization()) {
return;
}
$localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? '')); $localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? ''));
$channel = (int)($body['channel'] ?? 0); $channel = (int)($body['channel'] ?? 0);
$on = (bool)($body['on'] ?? false); $on = (bool)($body['on'] ?? false);

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