Compare commits
386
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ccd7749d0 | ||
|
|
b3ba3c8de5 | ||
|
|
4a65b669bd | ||
|
|
aaec443140 | ||
|
|
3cdf1571c5 | ||
|
|
36b934e835 | ||
|
|
5027d0c919 | ||
|
|
f0baadd59f | ||
|
|
19cacebaa1 | ||
|
|
4d9d61455f | ||
|
|
fc87b3a8aa | ||
|
|
8e0936001d | ||
|
|
af8968a87e | ||
|
|
e6a18ce5d8 | ||
|
|
b5c24ef80a | ||
|
|
df7153a5ba | ||
|
|
d06c78119b | ||
|
|
bdb1a0074b | ||
|
|
c0de0e9d6b | ||
|
|
574b263a54 | ||
|
|
36ff5bb438 | ||
|
|
1beca924fc | ||
|
|
d605eca574 | ||
|
|
cea469c95a | ||
|
|
7e85c74e60 | ||
|
|
67d62eff70 | ||
|
|
8ebbd52a99 | ||
|
|
6d6cc501db | ||
|
|
ce999afbb3 | ||
|
|
cb34b030c8 | ||
|
|
e26034dfae | ||
|
|
0aad41fd0f | ||
|
|
5c67fe419f | ||
|
|
aca8be51dc | ||
|
|
fb1f0883e1 | ||
|
|
6446eb2e36 | ||
|
|
a1224ec2f4 | ||
|
|
07441c4ed1 | ||
|
|
cd100f1180 | ||
|
|
4fc66c72b8 | ||
|
|
a3ea5fee83 | ||
|
|
ef8d97c821 | ||
|
|
327a77edf4 | ||
|
|
d8abc8f87d | ||
|
|
325b35beb7 | ||
|
|
49364864d2 | ||
|
|
a19178a042 | ||
|
|
91d3332d4e | ||
|
|
bedbf21c29 | ||
|
|
75c19bcce4 | ||
|
|
458fe7399d | ||
|
|
2b6a8eedcc | ||
|
|
c0ed107f75 | ||
|
|
30dceff0b5 | ||
|
|
716929bd7b | ||
|
|
3d221f3379 | ||
|
|
6f1c160fbb | ||
|
|
9694695f00 | ||
|
|
1d43221b4d | ||
|
|
33b7c3e51a | ||
|
|
1e64bd63b8 | ||
|
|
bcbc2481c3 | ||
|
|
8288a1069c | ||
|
|
1b99523366 | ||
|
|
6d739cfebc | ||
|
|
20071166f8 | ||
|
|
c23168afc5 | ||
|
|
72704b7806 | ||
|
|
f7485f0767 | ||
|
|
975909b6a1 | ||
|
|
1468e43ce2 | ||
|
|
ee2af5091c | ||
|
|
0672a68e8b | ||
|
|
c8804bc8dc | ||
|
|
b92d1f0bdf | ||
|
|
cc10371346 | ||
|
|
ac60596218 | ||
|
|
f4b9d71d40 | ||
|
|
77b1c8ec78 | ||
|
|
01221d8282 | ||
|
|
47068e6d7e | ||
|
|
46bdeded78 | ||
|
|
eefa521fc5 | ||
|
|
ec1988715d | ||
|
|
c3fb2e8651 | ||
|
|
0fb279fc5f | ||
|
|
3e970d9cb9 | ||
|
|
8c10c07cc9 | ||
|
|
18a8513b40 | ||
|
|
4c77b78c6c | ||
|
|
bb249da477 | ||
|
|
eb16a4e6ce | ||
|
|
a2e525fa9e | ||
|
|
0bf19c9d33 | ||
|
|
5850bfbce7 | ||
|
|
fd51a5b119 | ||
|
|
140365c8bb | ||
|
|
c9ceac8533 | ||
|
|
4266b933f5 | ||
|
|
72ec62d042 | ||
|
|
d24b50f751 | ||
|
|
21f5e6d9cf | ||
|
|
73b91ccec9 | ||
|
|
0c809a19da | ||
|
|
3a6685c345 | ||
|
|
a60983f328 | ||
|
|
e2c2eb21cb | ||
|
|
51c619b0c6 | ||
|
|
fe9daf1bf2 | ||
|
|
9c2d7140b4 | ||
|
|
1505464095 | ||
|
|
cc00fb2aed | ||
|
|
7f38cf2f7e | ||
|
|
d281dddbc1 | ||
|
|
267ec1bed1 | ||
|
|
a96f40cf13 | ||
|
|
d6190626ce | ||
|
|
ce8e6d0dab | ||
|
|
c13c2e2cab | ||
|
|
7380bc729b | ||
|
|
434a5049e2 | ||
|
|
6489706231 | ||
|
|
eb66b343ea | ||
|
|
e363f27da9 | ||
|
|
fbad5f767f | ||
|
|
94d9b347bf | ||
|
|
76744fd6c3 | ||
|
|
f5c1a34c29 | ||
|
|
22ad96bc8e | ||
|
|
8a749cffa3 | ||
|
|
cf5cf8d5eb | ||
|
|
eb14b7039b | ||
|
|
f09b1263c1 | ||
|
|
9ec8499d55 | ||
|
|
8d40cd6f9a | ||
|
|
ccffad3c7c | ||
|
|
4697c6b272 | ||
|
|
45e17e196c | ||
|
|
dcc81cbdc7 | ||
|
|
c5cb0a3bfe | ||
|
|
465f3ed027 | ||
|
|
80ff01f04e | ||
|
|
f6e4d851d3 | ||
|
|
cd4e3faea3 | ||
|
|
4cb9e68b33 | ||
|
|
0e7e79d205 | ||
|
|
a9ca7b41a7 | ||
|
|
3b3ed31bb7 | ||
|
|
cf9d5875ef | ||
|
|
4730eebdb4 | ||
|
|
b25ce9cb11 | ||
|
|
fdb98f1399 | ||
|
|
1dc758a3a3 | ||
|
|
032ce93d5e | ||
|
|
f8ced3b8f2 | ||
|
|
a81e239de8 | ||
|
|
9ea5a62577 | ||
|
|
af89a246db | ||
|
|
20c1973565 | ||
|
|
3555904423 | ||
|
|
a2dda5ea5b | ||
|
|
ce29cf9ccb | ||
|
|
e7481297c8 | ||
|
|
e3b38519fb | ||
|
|
d244c000c3 | ||
|
|
dcd57c7092 | ||
|
|
0a7e58fc01 | ||
|
|
bd7deaeded | ||
|
|
07a3ef6418 | ||
|
|
bffed6f5f3 | ||
|
|
bfec31f94b | ||
|
|
2123835aae | ||
|
|
11f06e8f53 | ||
|
|
6712368323 | ||
|
|
99fe659dbc | ||
|
|
357cfda46e | ||
|
|
9c85135a07 | ||
|
|
a8a47104dd | ||
|
|
2c0907c486 | ||
|
|
b1647b4ad1 | ||
|
|
0ae28af309 | ||
|
|
64d7e6f061 | ||
|
|
4f9a10402b | ||
|
|
5e8ec85943 | ||
|
|
20d6056e40 | ||
|
|
5be6bc0198 | ||
|
|
373aa7effb | ||
|
|
5282ee10ba | ||
|
|
06cba73a30 | ||
|
|
eab8394579 | ||
|
|
b88c2742e8 | ||
|
|
4ea5eeb942 | ||
|
|
e41b226529 | ||
|
|
7cb248a112 | ||
|
|
dfa0441266 | ||
|
|
d9dbd7dede | ||
|
|
3b8463e37f | ||
|
|
0e8b527ee4 | ||
|
|
86fb8bb700 | ||
|
|
6fbf7f271d | ||
|
|
fe5ebdc203 | ||
|
|
c6dbc0728f | ||
|
|
a0b1dcb3e3 | ||
|
|
38c4c32f07 | ||
|
|
b6beb9622b | ||
|
|
db80dad15f | ||
|
|
cf370a8035 | ||
|
|
0778776f00 | ||
|
|
ee55c23cde | ||
|
|
1f50c83f93 | ||
|
|
85f7bd1fc9 | ||
|
|
8f53e80ede | ||
|
|
2a1a730a8c | ||
|
|
7bb67b0470 | ||
|
|
1065973b33 | ||
|
|
1d05550cd3 | ||
|
|
2cc12c23cd | ||
|
|
b09ada0bc4 | ||
|
|
43dfac836a | ||
|
|
d1871f1420 | ||
|
|
08a1538ed6 | ||
|
|
f2fc4f6f18 | ||
|
|
503fd50c61 | ||
|
|
1d6df82c1c | ||
|
|
3fda0f9912 | ||
|
|
decc571307 | ||
|
|
ef237b5e87 | ||
|
|
828c177a57 | ||
|
|
c79219eb00 | ||
|
|
6995c3d1bc | ||
|
|
7436584598 | ||
|
|
06421beb6b | ||
|
|
7de4b96074 | ||
|
|
ea69c64fad | ||
|
|
652b89d23d | ||
|
|
bc4b7bde15 | ||
|
|
a5b674286a | ||
|
|
18bf7aa013 | ||
|
|
bf8262b64f | ||
|
|
fdb073f17f | ||
|
|
20fcd4ac16 | ||
|
|
0f7d76d96d | ||
|
|
324f2c856f | ||
|
|
84f203939c | ||
|
|
368501a8ce | ||
|
|
d9a36e4050 | ||
|
|
a5019efbda | ||
|
|
b16a07fdbb | ||
|
|
ca02fd3436 | ||
|
|
65283b8ad7 | ||
|
|
ad53041bfd | ||
|
|
b11b38a95b | ||
|
|
ba9c4d3b9f | ||
|
|
ded497b3d8 | ||
|
|
2fd3ce4877 | ||
|
|
64fc70a0a8 | ||
|
|
42acf26ee1 | ||
|
|
fe6eae862f | ||
|
|
eedde6c6d7 | ||
|
|
2782afde2e | ||
|
|
484529660b | ||
|
|
fbe700a4db | ||
|
|
6225c4b072 | ||
|
|
300a37fce3 | ||
|
|
c43618351e | ||
|
|
b3225c8d8b | ||
|
|
0f96247bf3 | ||
|
|
4703e07951 | ||
|
|
7ddda9ab03 | ||
|
|
4e9575cd87 | ||
|
|
ef82a95feb | ||
|
|
fd4ec3dda2 | ||
|
|
1616bd431a | ||
|
|
334a7a4401 | ||
|
|
22dd9f9c07 | ||
|
|
5684da1bc7 | ||
|
|
69cd039322 | ||
|
|
0dc7f813a8 | ||
|
|
a828e9bc25 | ||
|
|
8d2e71aaf3 | ||
|
|
721e2670dd | ||
|
|
b03500d2d1 | ||
|
|
ed9ebc2ac8 | ||
|
|
64beb38bae | ||
|
|
e13bbae01f | ||
|
|
5ba0f5f9ba | ||
|
|
bb5f1db1b3 | ||
|
|
cb63d10415 | ||
|
|
4183c3928c | ||
|
|
28bae85b2a | ||
|
|
f2db92de09 | ||
|
|
a41334f513 | ||
|
|
175fb3a35f | ||
|
|
8e6b29810a | ||
|
|
21e9b2c80f | ||
|
|
989d04167a | ||
|
|
286127c390 | ||
|
|
2ba87a4850 | ||
|
|
6658af814b | ||
|
|
2abd6d04e9 | ||
|
|
3107779b74 | ||
|
|
61a09dce87 | ||
|
|
a02ed69108 | ||
|
|
9b69aadca4 | ||
|
|
6204fb50f9 | ||
|
|
0a6a8aeab2 | ||
|
|
7c21b6463d | ||
|
|
d97cfda0ea | ||
|
|
aad5d77f41 | ||
|
|
3b132cad95 | ||
|
|
ab957092bd | ||
|
|
b8f65f242f | ||
|
|
688cb0a664 | ||
|
|
6eb4171fea | ||
|
|
ddba27a1be | ||
|
|
933b18b988 | ||
|
|
18c6852865 | ||
|
|
77403965f8 | ||
|
|
a3e2765ad4 | ||
|
|
787db994dd | ||
|
|
f8f603a38e | ||
|
|
31a7224272 | ||
|
|
492c81e27c | ||
|
|
45bfb1525a | ||
|
|
71ffa20811 | ||
|
|
a466c6291c | ||
|
|
9606d3b11d | ||
|
|
03b7fcd1b1 | ||
|
|
3d0f0f3391 | ||
|
|
e3257465a0 | ||
|
|
0c21f6e3a1 | ||
|
|
f1e5cacd0c | ||
|
|
a8d5320ae5 | ||
|
|
95ac0d3a2c | ||
|
|
1ed27dd467 | ||
|
|
c4bb7bbb8b | ||
|
|
c09b7ebe76 | ||
|
|
166ed6b92b | ||
|
|
8e528f3eae | ||
|
|
160772b832 | ||
|
|
c8a5c3969d | ||
|
|
bb98df9e73 | ||
|
|
fe3719530a | ||
|
|
603f497bef | ||
|
|
ee16db8ecc | ||
|
|
c5c33d3cf7 | ||
|
|
da05c5adb7 | ||
|
|
707cf67d5c | ||
|
|
09fa186028 | ||
|
|
5e6b340f8c | ||
|
|
04e47a2e6d | ||
|
|
572f5027d6 | ||
|
|
235e0268c2 | ||
|
|
65d639853b | ||
|
|
e856bbffec | ||
|
|
3ee5b789ce | ||
|
|
7f5722ff75 | ||
|
|
50b596af39 | ||
|
|
af06c4d81e | ||
|
|
41ed692299 | ||
|
|
31214f0af0 | ||
|
|
aceaa6b957 | ||
|
|
cd0e0f0e61 | ||
|
|
0db6b5269d | ||
|
|
3fb1eb9644 | ||
|
|
76dfcd70d1 | ||
|
|
b13abe0d30 | ||
|
|
270e5b970f | ||
|
|
5dac3211ff | ||
|
|
4d91fc8ead | ||
|
|
893ed1bda5 | ||
|
|
90ebec84bf | ||
|
|
bdf2a787d6 | ||
|
|
f8c254607d | ||
|
|
20eb92891a | ||
|
|
4cfe906f55 | ||
|
|
184ea1ca6c | ||
|
|
ae3657e7aa | ||
|
|
54de2e5674 | ||
|
|
eef436d44b | ||
|
|
b7aeb11801 | ||
|
|
d52ceb8513 | ||
|
|
1504f1b116 | ||
|
|
78462f3ae4 | ||
|
|
6ff6ce9b48 | ||
|
|
bc7c0280f2 |
+7
-1
@@ -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
|
||||
|
||||
+5
-2
@@ -31,6 +31,9 @@ CONFIG_DB_DATABASE=
|
||||
CONFIG_DB_PORT=3306
|
||||
CONFIG_DB_SSL_MODE=DISABLED
|
||||
|
||||
# Browser origins allowed to call the API. Path-like entries are normalized to origins by the PHP CORS policy.
|
||||
CORS=https://truckwash.io,https://www.truckwash.io,https://api.truckwash.io,https://api.truckwash.io:4433,https://api-v2.truckwash.io,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
|
||||
|
||||
# Debug DB credentials (used when CONFIG_DB_TARGET=debug)
|
||||
# Any blank debug value falls back to the live value above.
|
||||
CONFIG_DB_DEBUG_HOST=mysql-debug
|
||||
@@ -50,8 +53,8 @@ ECONOMIC_API_APP_SECRET_TOKEN=
|
||||
# Edge broker defaults for shell relay and gateway dispatch.
|
||||
EDGE_BROKER_URL=http://edge-broker:4300
|
||||
EDGE_PUBLIC_BROKER_URL=http://localhost/api/edge-broker
|
||||
EDGE_AUTH_MODE=manager
|
||||
EDGE_BROKER_SHARED_SECRET=truckwash-edge-dev
|
||||
EDGE_AUTH_MODE=strict
|
||||
EDGE_BROKER_SHARED_SECRET=
|
||||
|
||||
# Redis credentials
|
||||
REDIS_CONFIG_HOST=redis
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,15 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.webp binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
*.otf binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
*.webm binary
|
||||
@@ -9,25 +9,44 @@ on:
|
||||
|
||||
jobs:
|
||||
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]
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
checks: write
|
||||
contents: read
|
||||
pull-requests: read
|
||||
checks: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
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
|
||||
persist-credentials: false
|
||||
- name: Mark repository as safe for Git
|
||||
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
- name: Prepare Qodana cache directories
|
||||
run: |
|
||||
mkdir -p "${RUNNER_TEMP}/qodana/caches"
|
||||
mkdir -p "${RUNNER_TEMP}/qodana/results"
|
||||
- name: Detect Qodana Cloud token
|
||||
id: qodana-token
|
||||
env:
|
||||
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
|
||||
run: |
|
||||
if [ -n "${QODANA_TOKEN:-}" ]; then
|
||||
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "present=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: 'Qodana Scan'
|
||||
uses: JetBrains/qodana-action@v2025.3
|
||||
if: ${{ steps.qodana-token.outputs.present == 'true' }}
|
||||
uses: JetBrains/qodana-action@v2026.1
|
||||
with:
|
||||
pr-mode: false
|
||||
env:
|
||||
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
|
||||
QODANA_ENDPOINT: 'https://qodana.cloud'
|
||||
|
||||
- name: 'Skip Qodana Scan (missing cloud token)'
|
||||
if: ${{ steps.qodana-token.outputs.present != 'true' }}
|
||||
run: echo "Skipping Qodana because QODANA_TOKEN is not configured."
|
||||
|
||||
@@ -144,6 +144,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
cp .github/ci.env .env
|
||||
cp .github/ci.env.staging .env.staging
|
||||
printf '\nEDGE_PUBLIC_BROKER_URL=http://edge-broker:4300\n' >> .env
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -151,7 +152,7 @@ jobs:
|
||||
node-version: 22
|
||||
|
||||
- name: Boot local stack
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml up -d traefik redis mysql-debug edge-broker php1 caddy
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml up -d traefik redis mysql-debug edge-broker php1 php2 php3 php4 php5 caddy
|
||||
|
||||
- name: Sync PHP app checkout
|
||||
run: >
|
||||
@@ -163,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 -
|
||||
|
||||
- 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
|
||||
run: >
|
||||
@@ -240,6 +264,7 @@ jobs:
|
||||
-e EDGE_GATEWAY_E2E_BASE_URL="http://caddy" \
|
||||
-e EDGE_GATEWAY_E2E_COMPOSE_PROJECT="$compose_project" \
|
||||
-e EDGE_GATEWAY_E2E_COPY_CONFIG="true" \
|
||||
-e EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP="true" \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-w /workspace \
|
||||
node:22-alpine \
|
||||
@@ -253,3 +278,52 @@ jobs:
|
||||
- name: Tear down local stack
|
||||
if: always()
|
||||
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml down -v
|
||||
|
||||
release-manager-gate:
|
||||
name: Release Manager gate
|
||||
runs-on: [self-hosted, Linux, X64, default]
|
||||
needs: [php, edge-agent, edge-broker, edge-gateway-backend]
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
|
||||
|
||||
steps:
|
||||
- name: Record Release Manager API gate
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$RELEASE_MANAGER_GATE_TOKEN" || (echo "RELEASE_MANAGER_GATE_TOKEN is required" >&2; exit 1)
|
||||
response_file="$(mktemp)"
|
||||
http_code="$(curl --show-error --silent \
|
||||
--connect-timeout 10 \
|
||||
--retry 5 \
|
||||
--retry-all-errors \
|
||||
--retry-delay 15 \
|
||||
--retry-max-time 300 \
|
||||
-o "$response_file" \
|
||||
-w '%{http_code}' \
|
||||
-X POST "$RELEASE_MANAGER_GATE_URL" \
|
||||
-H "Authorization: Bearer $RELEASE_MANAGER_GATE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "{\"channel_slug\":\"stable\",\"app\":\"api\",\"repository\":\"$RELEASE_REPOSITORY\",\"branch\":\"$RELEASE_BRANCH\",\"expected_commit\":\"$RELEASE_EXPECTED_COMMIT\",\"workflow_url\":\"$RELEASE_WORKFLOW_URL\",\"auto_sync\":true,\"wait_timeout_seconds\":300,\"poll_interval_seconds\":10,\"required_checks\":[\"api_gateway\"]}")"
|
||||
response_body="$(cat "$response_file")"
|
||||
rm -f "$response_file"
|
||||
|
||||
if [[ "$http_code" =~ ^2[0-9][0-9]$ ]]; then
|
||||
printf '%s\n' "$response_body"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if printf '%s' "$response_body" | grep -qi '<b>Parse error</b>'; then
|
||||
echo "::warning::Release Manager API returned a PHP parse error while recording the gate. Treating this as a break-glass pass so a fix can be deployed."
|
||||
printf '%s\n' "$response_body"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf '%s\n' "$response_body"
|
||||
echo "Release Manager gate failed with HTTP $http_code." >&2
|
||||
exit 1
|
||||
env:
|
||||
RELEASE_MANAGER_GATE_URL: ${{ secrets.RELEASE_MANAGER_GATE_URL || 'https://api.truckwash.io/release/gate/test-runs' }}
|
||||
RELEASE_MANAGER_GATE_TOKEN: ${{ secrets.RELEASE_MANAGER_GATE_TOKEN }}
|
||||
RELEASE_REPOSITORY: ${{ github.repository }}
|
||||
RELEASE_BRANCH: ${{ github.ref_name }}
|
||||
RELEASE_EXPECTED_COMMIT: ${{ github.sha }}
|
||||
RELEASE_WORKFLOW_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
|
||||
@@ -10,5 +10,7 @@
|
||||
/.idea/
|
||||
.env
|
||||
/services/caddy/logs*
|
||||
.env.old
|
||||
/.tmp/
|
||||
/.env.staging
|
||||
/services/nginx/app/storage/replication-bootstrap.json
|
||||
@@ -40,6 +40,7 @@ COPY . /var/www/html
|
||||
|
||||
# Copy Nginx configuration file
|
||||
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
|
||||
COPY --from=composer:2.6 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
@@ -47,10 +47,12 @@ RUN set -eux; \
|
||||
|
||||
COPY services/nginx/app/ /var/www/html/
|
||||
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/start.sh /usr/local/bin/coolify-api-start
|
||||
|
||||
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; \
|
||||
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; \
|
||||
|
||||
Binary file not shown.
+1
-1
@@ -10,7 +10,7 @@ $CONFIG_DB = [
|
||||
$DEBUG = true; // Set to true to enable debugging (Error messages will be shown, and this should never be used in production)
|
||||
$USE_PROD_ECONOMIC_IN_DEBUG = true; // Set to true to use the production economic API in debug mode
|
||||
$ENCRYPTION_KEY = ''; // 44 Characters long encryption key
|
||||
$CORS = '*'; // Set to the domain that should be allowed to access the API e.g. https://example.com
|
||||
$CORS = '*'; // Set to comma-separated allowed origins e.g. https://example.com,https://api-v2.truckwash.io
|
||||
$ECONOMIC_API = [
|
||||
'app_access_grant' => '', // Economic API access grant token (1)
|
||||
'app_access_grant2' => '', // Economic API access grant token (2)
|
||||
|
||||
@@ -52,9 +52,9 @@ services:
|
||||
dockerfile: services/edge-broker/Dockerfile
|
||||
container_name: edge-broker
|
||||
environment:
|
||||
EDGE_AUTH_MODE: ${EDGE_AUTH_MODE:-manager}
|
||||
EDGE_AUTH_MODE: ${EDGE_AUTH_MODE:-strict}
|
||||
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:
|
||||
- "traefik.enable=true"
|
||||
- "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.services.edge-broker.loadbalancer.server.port=4300"
|
||||
|
||||
|
||||
caddy:
|
||||
image: caddy:2.7.6-alpine
|
||||
container_name: caddy
|
||||
@@ -113,7 +114,7 @@ services:
|
||||
environment:
|
||||
AUTO_COMPOSER_INSTALL: "true"
|
||||
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:
|
||||
- ./services/nginx/app:/var/www/html
|
||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
@@ -134,7 +135,7 @@ services:
|
||||
environment:
|
||||
AUTO_COMPOSER_INSTALL: "false"
|
||||
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:
|
||||
- ./services/nginx/app:/var/www/html
|
||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
|
||||
+14
-10
@@ -3,6 +3,8 @@ services:
|
||||
traefik:
|
||||
image: traefik:2.11
|
||||
container_name: traefik
|
||||
group_add:
|
||||
- "${DOCKER_SOCKET_GID:-65534}"
|
||||
ports:
|
||||
- "${TRAEFIK_WEB_PORT:-80}:80"
|
||||
- "${TRAEFIK_WEBSECURE_PORT:-443}:443"
|
||||
@@ -101,8 +103,10 @@ services:
|
||||
mysql-debug:
|
||||
image: mysql:8.4
|
||||
container_name: mysql-debug
|
||||
profiles: [dev]
|
||||
command: ["mysqld", "--innodb-use-native-aio=0"]
|
||||
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}
|
||||
ports:
|
||||
- "3307:3306"
|
||||
@@ -121,9 +125,9 @@ services:
|
||||
dockerfile: services/edge-broker/Dockerfile
|
||||
container_name: edge-broker
|
||||
environment:
|
||||
EDGE_AUTH_MODE: ${EDGE_AUTH_MODE:-manager}
|
||||
EDGE_AUTH_MODE: ${EDGE_AUTH_MODE:-strict}
|
||||
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:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.edge-broker-api.rule=Host(`api.truckwash.dk`) && PathPrefix(`/edge-broker`)"
|
||||
@@ -307,7 +311,7 @@ services:
|
||||
environment:
|
||||
AUTO_COMPOSER_INSTALL: "true"
|
||||
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:
|
||||
- ./services/nginx/app:/var/www/html
|
||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
@@ -327,7 +331,7 @@ services:
|
||||
environment:
|
||||
AUTO_COMPOSER_INSTALL: "false"
|
||||
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:
|
||||
- ./services/nginx/app:/var/www/html
|
||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
@@ -347,7 +351,7 @@ services:
|
||||
environment:
|
||||
AUTO_COMPOSER_INSTALL: "false"
|
||||
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:
|
||||
- ./services/nginx/app:/var/www/html
|
||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
@@ -367,7 +371,7 @@ services:
|
||||
environment:
|
||||
AUTO_COMPOSER_INSTALL: "false"
|
||||
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:
|
||||
- ./services/nginx/app:/var/www/html
|
||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
@@ -387,7 +391,7 @@ services:
|
||||
environment:
|
||||
AUTO_COMPOSER_INSTALL: "false"
|
||||
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:
|
||||
- ./services/nginx/app:/var/www/html
|
||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
@@ -407,7 +411,7 @@ services:
|
||||
environment:
|
||||
AUTO_COMPOSER_INSTALL: "false"
|
||||
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:
|
||||
- ./services/nginx/staging:/var/www/html
|
||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
@@ -427,7 +431,7 @@ services:
|
||||
environment:
|
||||
AUTO_COMPOSER_INSTALL: "false"
|
||||
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:
|
||||
- ./services/nginx/app:/var/www/html
|
||||
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
|
||||
|
||||
@@ -3357,6 +3357,9 @@
|
||||
},
|
||||
"email_notifications_enabled": {
|
||||
"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": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -15496,6 +15547,39 @@
|
||||
"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": {
|
||||
"type": "object",
|
||||
"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": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
+3195
-203
File diff suppressed because it is too large
Load Diff
+132545
File diff suppressed because one or more lines are too long
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
suite="${1:-}"
|
||||
case "$suite" in
|
||||
unit|integration|api|legacy|all)
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 <unit|integration|api|legacy|all>" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
repo_root="$(CDPATH= cd -- "$script_dir/.." && pwd)"
|
||||
cd "$repo_root"
|
||||
|
||||
compose_files="-f docker-compose.yml -f .github/docker-compose.ci.yml"
|
||||
project_suffix="$(date +%s)-$$"
|
||||
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-php-local-${suite}-${project_suffix}}"
|
||||
|
||||
log_dir=".tmp/ci-logs/$suite"
|
||||
mkdir -p "$log_dir"
|
||||
|
||||
env_backup_dir=".tmp/php-ci-env-backup-$project_suffix"
|
||||
mkdir -p "$env_backup_dir"
|
||||
had_env=0
|
||||
had_env_staging=0
|
||||
if [ -f .env ]; then
|
||||
cp .env "$env_backup_dir/env"
|
||||
had_env=1
|
||||
fi
|
||||
if [ -f .env.staging ]; then
|
||||
cp .env.staging "$env_backup_dir/env.staging"
|
||||
had_env_staging=1
|
||||
fi
|
||||
|
||||
cp .github/ci.env .env
|
||||
cp .github/ci.env.staging .env.staging
|
||||
|
||||
collect_logs() {
|
||||
status="$1"
|
||||
if [ "$status" -eq 0 ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
mkdir -p "$log_dir"
|
||||
docker compose $compose_files ps > "$log_dir/docker-compose-ps.txt" 2>&1 || true
|
||||
docker compose $compose_files logs --no-color > "$log_dir/docker-compose.log" 2>&1 || true
|
||||
docker compose $compose_files cp php1:/var/www/html/build/logs "$log_dir/app-build-logs" >/dev/null 2>&1 || true
|
||||
docker compose $compose_files cp php1:/var/log/php "$log_dir/php-logs" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
status="$?"
|
||||
collect_logs "$status"
|
||||
docker compose $compose_files down -v >/dev/null 2>&1 || true
|
||||
if [ "$had_env" -eq 1 ]; then
|
||||
cp "$env_backup_dir/env" .env
|
||||
else
|
||||
rm -f .env
|
||||
fi
|
||||
if [ "$had_env_staging" -eq 1 ]; then
|
||||
cp "$env_backup_dir/env.staging" .env.staging
|
||||
else
|
||||
rm -f .env.staging
|
||||
fi
|
||||
rm -rf "$env_backup_dir"
|
||||
exit "$status"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
docker compose $compose_files up -d redis mysql-debug php1
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc '
|
||||
set -eu
|
||||
for i in $(seq 1 90); do
|
||||
if MYSQL_PWD="${CONFIG_DB_PASSWORD:-debug_root_password}" mysqladmin \
|
||||
-h "${CONFIG_DB_HOST:-mysql-debug}" \
|
||||
-P "${CONFIG_DB_PORT:-3306}" \
|
||||
-u "${CONFIG_DB_USER:-root}" \
|
||||
ping --silent >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "Timed out waiting for mysql-debug" >&2
|
||||
exit 1
|
||||
'
|
||||
|
||||
tar \
|
||||
--exclude='./vendor' \
|
||||
--exclude='./.phpunit.cache' \
|
||||
--exclude='./build/logs' \
|
||||
-C services/nginx/app -cf - . \
|
||||
| docker compose $compose_files exec -T php1 tar -C /var/www/html -xf -
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc \
|
||||
'cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress'
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc \
|
||||
"cd /var/www/html && composer test:ci:$suite"
|
||||
@@ -10,7 +10,7 @@ import { promisify } from "node:util";
|
||||
import { DEFAULT_CONFIG_FILE_NAME, DEFAULT_HOST_API_URL } from "./test-gateway.mjs";
|
||||
|
||||
const execFile = promisify(execFileCallback);
|
||||
const COMPOSE_SERVICES = ["traefik", "redis", "mysql-debug", "edge-broker", "php1", "caddy"];
|
||||
const COMPOSE_SERVICES = ["traefik", "redis", "mysql-debug", "edge-broker", "php1", "php2", "php3", "php4", "php5", "caddy"];
|
||||
|
||||
function composeArgs(projectName, args) {
|
||||
return ["compose", "-p", projectName, ...args];
|
||||
@@ -514,6 +514,10 @@ function shouldCopyGatewayConfig() {
|
||||
return /^(1|true|yes)$/i.test(String(process.env.EDGE_GATEWAY_E2E_COPY_CONFIG || "").trim());
|
||||
}
|
||||
|
||||
function shouldSkipComposeUp() {
|
||||
return /^(1|true|yes)$/i.test(String(process.env.EDGE_GATEWAY_E2E_SKIP_COMPOSE_UP || "").trim());
|
||||
}
|
||||
|
||||
function collectMessages(rows) {
|
||||
return Array.isArray(rows)
|
||||
? rows
|
||||
@@ -570,7 +574,9 @@ async function main() {
|
||||
let runnerNetworkAttached = false;
|
||||
|
||||
try {
|
||||
await ensureComposeServices(rootDir, composeProject);
|
||||
if (!shouldSkipComposeUp()) {
|
||||
await ensureComposeServices(rootDir, composeProject);
|
||||
}
|
||||
runnerNetworkAttached = await connectCurrentContainerToComposeNetwork(rootDir, composeProject);
|
||||
baseUrl = await waitForApiReady(baseUrl, rootDir, composeProject, runnerNetworkAttached);
|
||||
process.stdout.write(`Using API base URL ${baseUrl}\n`);
|
||||
|
||||
+45
-4
@@ -51,6 +51,39 @@ collect_logs() {
|
||||
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() {
|
||||
status="$?"
|
||||
collect_logs "$status"
|
||||
@@ -70,7 +103,7 @@ cleanup() {
|
||||
}
|
||||
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 '
|
||||
set -eu
|
||||
@@ -95,8 +128,16 @@ tar \
|
||||
-C services/nginx/app -cf - . \
|
||||
| docker compose $compose_files exec -T php1 tar -C /var/www/html -xf -
|
||||
|
||||
docker compose $compose_files exec -T php1 sh -lc \
|
||||
'cd /var/www/html && composer install --no-interaction --prefer-dist --no-progress'
|
||||
docker compose $compose_files exec -T php1 sh -lc 'rm -rf /var/www/repo-root && mkdir -p /var/www/repo-root'
|
||||
tar \
|
||||
-cf - \
|
||||
Dockerfile \
|
||||
Dockerfile.coolify-api \
|
||||
services/php/Dockerfile \
|
||||
services/php/php-fpm-pool.conf \
|
||||
| docker compose $compose_files exec -T php1 tar -C /var/www/repo-root -xf -
|
||||
|
||||
composer_install
|
||||
|
||||
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"
|
||||
|
||||
@@ -25,6 +25,16 @@ function composeArgs(projectName, args) {
|
||||
return ["compose", "-p", projectName, ...args];
|
||||
}
|
||||
|
||||
function usesWindowsPathSyntax(filePath) {
|
||||
return /^[A-Za-z]:($|[\\/])/.test(filePath) || filePath.startsWith("\\\\") || filePath.includes("\\");
|
||||
}
|
||||
|
||||
function pathForInputs(...filePaths) {
|
||||
const hasWindowsPath = filePaths.some((filePath) => usesWindowsPathSyntax(String(filePath || "")));
|
||||
|
||||
return hasWindowsPath ? path.win32 : path;
|
||||
}
|
||||
|
||||
async function resolveRootDir(scriptPath) {
|
||||
const cwd = process.cwd();
|
||||
|
||||
@@ -66,7 +76,7 @@ export function resolveComposeProjectName(rootDir, env = process.env) {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
return path.basename(rootDir);
|
||||
return pathForInputs(rootDir).basename(rootDir);
|
||||
}
|
||||
|
||||
export function resolveComposeNetworkName(rootDir, env = process.env) {
|
||||
@@ -74,11 +84,13 @@ export function resolveComposeNetworkName(rootDir, env = process.env) {
|
||||
}
|
||||
|
||||
export function resolveConfigDirectory(rootDir, explicitDir = null) {
|
||||
const pathModule = pathForInputs(rootDir, explicitDir);
|
||||
|
||||
if (explicitDir) {
|
||||
return path.resolve(rootDir, explicitDir);
|
||||
return pathModule.resolve(rootDir, explicitDir);
|
||||
}
|
||||
|
||||
return path.join(rootDir, ".tmp", "test-gateway");
|
||||
return pathModule.join(rootDir, ".tmp", "test-gateway");
|
||||
}
|
||||
|
||||
export function shouldClaimGateway(existingConfig = {}, installToken = "") {
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
# 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 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_fastcgi php1:9000 php2:9000 php3:9000 php4:9000 php5:9000
|
||||
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
# 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 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_fastcgi php-staging:9000
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Vendored
+32
-2
@@ -18,6 +18,7 @@ const DEFAULT_UPDATE_VERIFY_INTERVAL_MS = 500;
|
||||
const DEFAULT_UPDATE_RESTART_GRACE_MS = 150;
|
||||
const DEFAULT_BROKER_RECONNECT_DELAY_MS = 1500;
|
||||
const DEFAULT_SHELLY_LOCAL_HTTP_TIMEOUT_MS = 1200;
|
||||
const MAX_RELAY_TOGGLE_AFTER_SECONDS = 5;
|
||||
const UPDATE_VERIFY_COMMAND = "post-update-verify";
|
||||
const execFile = promisify(execFileCallback);
|
||||
|
||||
@@ -151,6 +152,10 @@ function buildTransportHeartbeatState(brokerState = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function isShellAccessEnabled(config = {}) {
|
||||
return config.enableShellAccess === true;
|
||||
}
|
||||
|
||||
function normalizeBrokerBaseUrl(value) {
|
||||
const trimmed = String(value || "").trim().replace(/\/+$/, "");
|
||||
if (trimmed === "") {
|
||||
@@ -478,7 +483,7 @@ function resolveRelayToggleAfterSeconds(payload = {}) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.floor(configured);
|
||||
return Math.min(Math.floor(configured), MAX_RELAY_TOGGLE_AFTER_SECONDS);
|
||||
}
|
||||
|
||||
async function fetchJson(url, fetchImpl = fetch, options = {}) {
|
||||
@@ -754,6 +759,15 @@ async function fetchArtifactBuffer(url, expectedSha256, label, fetchImpl = fetch
|
||||
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);
|
||||
if (!response.ok) {
|
||||
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 sha256 = createHash("sha256").update(buffer).digest("hex");
|
||||
if (expectedSha256 && String(expectedSha256).toLowerCase() !== sha256.toLowerCase()) {
|
||||
if (normalizedExpectedSha256 !== sha256.toLowerCase()) {
|
||||
throw new Error(`${label} checksum mismatch`);
|
||||
}
|
||||
|
||||
@@ -1785,6 +1799,10 @@ export async function processPolledShellAction(config, action, shell, fetchImpl
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isShellAccessEnabled(config)) {
|
||||
throw new Error("Shell access is disabled by local configuration");
|
||||
}
|
||||
|
||||
if (actionType === "OPEN") {
|
||||
await shell.open(payload);
|
||||
} else if (actionType === "INPUT") {
|
||||
@@ -1919,18 +1937,30 @@ function createBrokerBridge({
|
||||
}
|
||||
|
||||
if (message.type === "OPEN_ROOT_SHELL") {
|
||||
if (!isShellAccessEnabled(config)) {
|
||||
throw new Error("Shell access is disabled by local configuration");
|
||||
}
|
||||
await shell.open(message.payload || {});
|
||||
return;
|
||||
}
|
||||
if (message.type === "SHELL_INPUT") {
|
||||
if (!isShellAccessEnabled(config)) {
|
||||
throw new Error("Shell access is disabled by local configuration");
|
||||
}
|
||||
shell.input(message.payload || {});
|
||||
return;
|
||||
}
|
||||
if (message.type === "RESIZE_ROOT_SHELL") {
|
||||
if (!isShellAccessEnabled(config)) {
|
||||
throw new Error("Shell access is disabled by local configuration");
|
||||
}
|
||||
shell.resize(message.payload || {});
|
||||
return;
|
||||
}
|
||||
if (message.type === "CLOSE_ROOT_SHELL") {
|
||||
if (!isShellAccessEnabled(config)) {
|
||||
throw new Error("Shell access is disabled by local configuration");
|
||||
}
|
||||
shell.close(message.payload || {});
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile as execFileCallback } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
getRelayStatus,
|
||||
loadConfig,
|
||||
parseCliArgs,
|
||||
processPolledShellAction,
|
||||
processPolledCommand,
|
||||
runCli,
|
||||
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" } = {}) {
|
||||
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/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 () => {
|
||||
@@ -294,19 +325,23 @@ test("runUpdate stages a pending verification restart after installing new artif
|
||||
execCalls.push({ command, args, options });
|
||||
return { stdout: "{}" };
|
||||
};
|
||||
const agentBody = "// new agent\n";
|
||||
const packageBody = JSON.stringify({ name: "new-edge-agent" }, null, 2);
|
||||
const fakeFetch = async (url) => {
|
||||
if (String(url).endsWith("/agent.mjs")) {
|
||||
return makeFetchResponse("// new agent\n");
|
||||
return makeFetchResponse(agentBody);
|
||||
}
|
||||
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}`);
|
||||
};
|
||||
|
||||
const result = await runUpdate({
|
||||
artifactUrl: "https://api.example.test/edge-agent/artifacts/agent.mjs",
|
||||
sha256: sha256Hex(agentBody),
|
||||
packageUrl: "https://api.example.test/edge-agent/artifacts/package.json",
|
||||
packageSha256: sha256Hex(packageBody),
|
||||
targetVersion: "1.1.0",
|
||||
releaseChannel: "stable",
|
||||
restartMode: "spawn",
|
||||
@@ -334,6 +369,45 @@ test("runUpdate stages a pending verification restart after installing new artif
|
||||
|
||||
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 () => {
|
||||
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,
|
||||
shellActionPollTimeoutSeconds: 0,
|
||||
shellActionPollRetryDelayMs: 5,
|
||||
enableShellAccess: true,
|
||||
}));
|
||||
|
||||
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 () => {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "edge-agent-status-"));
|
||||
const configPath = path.join(tempDir, "config.json");
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { WebSocketServer } from "ws";
|
||||
|
||||
const DEFAULT_SHELL_OPEN_TIMEOUT_MS = 15000;
|
||||
const TELEMETRY_INGEST_ERROR_MESSAGE = "Telemetry ingestion failed";
|
||||
|
||||
function parseJsonBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -55,7 +56,33 @@ function resolveAuthMode(options = {}, managerUrl = "") {
|
||||
if (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) {
|
||||
@@ -150,7 +177,7 @@ function rejectUpgrade(socket, statusCode, errorCode, message, details = {}) {
|
||||
}
|
||||
|
||||
export function createBrokerServer(options = {}) {
|
||||
const sharedSecret = options.sharedSecret ?? process.env.EDGE_BROKER_SHARED_SECRET ?? "";
|
||||
const sharedSecret = resolveSharedSecret(options);
|
||||
const managerUrl = resolveManagerUrl(options);
|
||||
const authMode = resolveAuthMode(options, managerUrl);
|
||||
const commandTimeoutMs = options.commandTimeoutMs ?? 10000;
|
||||
@@ -460,25 +487,19 @@ export function createBrokerServer(options = {}) {
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/diagnostics/shared-secret") {
|
||||
if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) {
|
||||
jsonResponse(res, 403, {
|
||||
ok: false,
|
||||
error: "Forbidden",
|
||||
shared_secret_required: true,
|
||||
});
|
||||
if (!requireSharedSecret(req, res, sharedSecret)) {
|
||||
return;
|
||||
}
|
||||
|
||||
jsonResponse(res, 200, {
|
||||
ok: true,
|
||||
shared_secret_required: Boolean(sharedSecret),
|
||||
shared_secret_required: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && /^\/api\/gateways\/\d+\/commands$/.test(url.pathname)) {
|
||||
if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) {
|
||||
jsonResponse(res, 403, { error: "Forbidden" });
|
||||
if (!requireSharedSecret(req, res, sharedSecret)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -521,8 +542,7 @@ export function createBrokerServer(options = {}) {
|
||||
}
|
||||
|
||||
if (req.method === "POST" && /^\/api\/gateways\/\d+\/sync$/.test(url.pathname)) {
|
||||
if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) {
|
||||
jsonResponse(res, 403, { error: "Forbidden" });
|
||||
if (!requireSharedSecret(req, res, sharedSecret)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -556,12 +576,13 @@ export function createBrokerServer(options = {}) {
|
||||
gatewayInfo = await validateAgent({ gatewayId, token, headers: req.headers });
|
||||
} catch (error) {
|
||||
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",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
const existing = agents.get(gatewayId);
|
||||
if (existing && existing.readyState < 2) {
|
||||
@@ -622,12 +643,13 @@ export function createBrokerServer(options = {}) {
|
||||
try {
|
||||
session = await validateShellSession({ token, headers: req.headers });
|
||||
} 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",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
ws.sessionToken = token;
|
||||
ws.sessionInfo = session;
|
||||
@@ -722,7 +744,7 @@ export function createBrokerServer(options = {}) {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
rejectUpgrade(socket, 500, "websocket_upgrade_failed", normalizeErrorMessage(error, "WebSocket upgrade failed."));
|
||||
rejectUpgrade(socket, 500, "websocket_upgrade_failed", "WebSocket upgrade failed.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -765,8 +787,8 @@ export function createBrokerServer(options = {}) {
|
||||
let ingestError = null;
|
||||
try {
|
||||
ingested = await ingestTelemetry(String(ws.gatewayId), payload);
|
||||
} catch (error) {
|
||||
ingestError = error instanceof Error ? error.message : String(error);
|
||||
} catch {
|
||||
ingestError = TELEMETRY_INGEST_ERROR_MESSAGE;
|
||||
}
|
||||
const fallbackStatistics = {
|
||||
system_metrics: payload?.metadata?.system_metrics || {},
|
||||
|
||||
@@ -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) {
|
||||
return new Promise((resolve, reject) => {
|
||||
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}`);
|
||||
}
|
||||
|
||||
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 = {
|
||||
EDGE_AUTH_MODE: process.env.EDGE_AUTH_MODE,
|
||||
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;
|
||||
try {
|
||||
broker = createBrokerServer({ sharedSecret: "secret" });
|
||||
assert.equal(broker.state.authMode, "manager");
|
||||
assert.equal(broker.state.authMode, "strict");
|
||||
assert.equal(broker.state.managerUrl, "");
|
||||
|
||||
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.match(shellResponse, /^HTTP\/1\.1 401 Unauthorized/m);
|
||||
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.match(agentResponse, /^HTTP\/1\.1 503 Service Unavailable/m);
|
||||
assert.match(agentResponse, /"error_code":"agent_validation_failed"/);
|
||||
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 {
|
||||
if (broker) {
|
||||
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 () => {
|
||||
const broker = createBrokerServer({ authMode: "stub", sharedSecret: "secret", commandTimeoutMs: 2000 });
|
||||
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, /"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));
|
||||
|
||||
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 () => {
|
||||
const closedSessions = [];
|
||||
const broker = createBrokerServer({
|
||||
@@ -418,6 +502,17 @@ test("broker closes browser shell sessions when the agent disconnects before she
|
||||
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 () => {
|
||||
const broker = createBrokerServer({
|
||||
authMode: "stub",
|
||||
@@ -743,9 +838,16 @@ test("broker still fans out telemetry when manager ingestion fails", async () =>
|
||||
);
|
||||
|
||||
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" }
|
||||
);
|
||||
assert.ok(
|
||||
browserMessages.every((message) => message.error !== "manager unavailable"),
|
||||
"raw manager errors must not be sent to browser streams"
|
||||
);
|
||||
assert.ok(
|
||||
browserMessages.some(
|
||||
(message) => message.type === "stats.updated" && message.statistics?.system_metrics?.cpu_usage_pct === 31
|
||||
|
||||
@@ -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", () => {
|
||||
const serviceBlock = readComposeServiceBlock(baseComposeSource, "edge-broker");
|
||||
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, /traefik\.http\.routers\.edge-broker-api\.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", () => {
|
||||
const serviceBlock = readComposeServiceBlock(exampleComposeSource, "edge-broker");
|
||||
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, /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`\)/);
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
test("php services receive broker websocket environment defaults", () => {
|
||||
test("compose config does not provide insecure broker secret defaults", () => {
|
||||
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_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);
|
||||
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_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
@@ -12120,3 +12120,335 @@
|
||||
[Mon Apr 27 16:09:11 2026] 127.0.0.1:40070 Closing
|
||||
[Mon Apr 27 16:09:12 2026] 127.0.0.1:41964 Accepted
|
||||
[Mon Apr 27 16:09:54 2026] 127.0.0.1:41964 Closing
|
||||
[Tue May 26 09:18:49 2026] PHP 8.2.15 Development Server (http://127.0.0.1:35029) started
|
||||
[Tue May 26 09:18:49 2026] 127.0.0.1:35080 Accepted
|
||||
[Tue May 26 09:18:53 2026] 127.0.0.1:35080 Closing
|
||||
[Tue May 26 09:18:53 2026] 127.0.0.1:35088 Accepted
|
||||
[Tue May 26 09:18:56 2026] 127.0.0.1:35088 Closing
|
||||
[Tue May 26 09:18:58 2026] 127.0.0.1:39708 Accepted
|
||||
[Tue May 26 09:19:01 2026] 127.0.0.1:39708 Closing
|
||||
[Tue May 26 09:19:02 2026] 127.0.0.1:39714 Accepted
|
||||
[Tue May 26 09:19:06 2026] 127.0.0.1:39714 Closing
|
||||
[Tue May 26 09:19:05 2026] 127.0.0.1:38256 Accepted
|
||||
[Tue May 26 09:19:08 2026] 127.0.0.1:38256 Closing
|
||||
[Tue May 26 09:19:09 2026] 127.0.0.1:38268 Accepted
|
||||
[Tue May 26 09:19:12 2026] 127.0.0.1:38268 Closing
|
||||
[Tue May 26 09:19:13 2026] 127.0.0.1:37926 Accepted
|
||||
[Tue May 26 09:19:17 2026] 127.0.0.1:37926 Closing
|
||||
[Tue May 26 09:21:24 2026] PHP 8.2.15 Development Server (http://127.0.0.1:33343) started
|
||||
[Tue May 26 09:21:25 2026] 127.0.0.1:53732 Accepted
|
||||
[Tue May 26 09:21:27 2026] 127.0.0.1:53732 Closing
|
||||
[Tue May 26 09:21:27 2026] 127.0.0.1:55472 Accepted
|
||||
[Tue May 26 09:21:30 2026] 127.0.0.1:55472 Closing
|
||||
[Tue May 26 09:21:32 2026] 127.0.0.1:55484 Accepted
|
||||
[Tue May 26 09:21:35 2026] 127.0.0.1:55484 Closing
|
||||
[Tue May 26 09:21:36 2026] 127.0.0.1:44478 Accepted
|
||||
[Tue May 26 09:21:40 2026] 127.0.0.1:44478 Closing
|
||||
[Tue May 26 09:21:41 2026] 127.0.0.1:44486 Accepted
|
||||
[Tue May 26 09:21:46 2026] 127.0.0.1:44486 Closing
|
||||
[Tue May 26 09:21:47 2026] 127.0.0.1:50508 Accepted
|
||||
[Tue May 26 09:21:53 2026] 127.0.0.1:50508 Closing
|
||||
[Tue May 26 09:21:55 2026] 127.0.0.1:42034 Accepted
|
||||
[Tue May 26 09:22:00 2026] 127.0.0.1:42034 Closing
|
||||
[Tue May 26 09:23:59 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41083) started
|
||||
[Tue May 26 09:23:59 2026] 127.0.0.1:45896 Accepted
|
||||
[Tue May 26 09:24:04 2026] 127.0.0.1:45896 Closing
|
||||
[Tue May 26 09:24:04 2026] 127.0.0.1:45908 Accepted
|
||||
[Tue May 26 09:24:07 2026] 127.0.0.1:45908 Closing
|
||||
[Tue May 26 09:25:43 2026] PHP 8.2.15 Development Server (http://127.0.0.1:38485) started
|
||||
[Tue May 26 09:25:43 2026] 127.0.0.1:34288 Accepted
|
||||
[Tue May 26 09:25:47 2026] 127.0.0.1:34288 Closing
|
||||
[Tue May 26 09:25:47 2026] 127.0.0.1:34290 Accepted
|
||||
[Tue May 26 09:25:51 2026] 127.0.0.1:34290 Closing
|
||||
[Tue May 26 09:25:51 2026] 127.0.0.1:41802 Accepted
|
||||
[Tue May 26 09:25:55 2026] 127.0.0.1:41802 Closing
|
||||
[Tue May 26 09:27:23 2026] PHP 8.2.15 Development Server (http://127.0.0.1:37463) started
|
||||
[Tue May 26 09:27:24 2026] 127.0.0.1:43206 Accepted
|
||||
[Tue May 26 09:27:26 2026] 127.0.0.1:43206 Closing
|
||||
[Tue May 26 09:27:26 2026] 127.0.0.1:43212 Accepted
|
||||
[Tue May 26 09:27:33 2026] 127.0.0.1:43212 Closing
|
||||
[Tue May 26 09:27:34 2026] 127.0.0.1:51102 Accepted
|
||||
[Tue May 26 09:27:39 2026] 127.0.0.1:51102 Closing
|
||||
[Tue May 26 09:28:26 2026] PHP 8.2.15 Development Server (http://127.0.0.1:46559) started
|
||||
[Tue May 26 09:28:26 2026] 127.0.0.1:59372 Accepted
|
||||
[Tue May 26 09:28:28 2026] 127.0.0.1:59372 Closing
|
||||
[Tue May 26 09:28:28 2026] 127.0.0.1:59378 Accepted
|
||||
[Tue May 26 09:28:30 2026] 127.0.0.1:59378 Closing
|
||||
[Tue May 26 09:28:31 2026] 127.0.0.1:54846 Accepted
|
||||
[Tue May 26 09:28:31 2026] 127.0.0.1:54846 Closing
|
||||
[Tue May 26 09:28:33 2026] 127.0.0.1:54850 Accepted
|
||||
[Tue May 26 09:28:34 2026] 127.0.0.1:54850 Closing
|
||||
[Tue May 26 09:28:35 2026] 127.0.0.1:54854 Accepted
|
||||
[Tue May 26 09:28:36 2026] 127.0.0.1:54854 Closing
|
||||
[Tue May 26 09:28:36 2026] 127.0.0.1:54864 Accepted
|
||||
[Tue May 26 09:28:41 2026] 127.0.0.1:54864 Closing
|
||||
[Tue May 26 09:28:42 2026] 127.0.0.1:52868 Accepted
|
||||
[Tue May 26 09:28:46 2026] 127.0.0.1:52868 Closing
|
||||
[Tue May 26 09:30:14 2026] PHP 8.2.15 Development Server (http://127.0.0.1:41205) started
|
||||
[Tue May 26 09:30:14 2026] 127.0.0.1:56928 Accepted
|
||||
[Tue May 26 09:30:15 2026] 127.0.0.1:56928 Closing
|
||||
[Tue May 26 09:30:15 2026] 127.0.0.1:56938 Accepted
|
||||
[Tue May 26 09:30:18 2026] 127.0.0.1:56938 Closing
|
||||
[Tue May 26 09:30:49 2026] PHP 8.2.15 Development Server (http://127.0.0.1:44523) started
|
||||
[Tue May 26 09:30:49 2026] 127.0.0.1:54004 Accepted
|
||||
[Tue May 26 09:30:52 2026] 127.0.0.1:54004 Closing
|
||||
[Tue May 26 09:30:52 2026] 127.0.0.1:38720 Accepted
|
||||
[Tue May 26 09:30:51 2026] 127.0.0.1:38720 Closing
|
||||
[Tue May 26 09:30:53 2026] 127.0.0.1:38734 Accepted
|
||||
[Tue May 26 09:30:56 2026] 127.0.0.1:38734 Closing
|
||||
[Tue May 26 09:30:57 2026] 127.0.0.1:38738 Accepted
|
||||
[Tue May 26 09:31:00 2026] 127.0.0.1:38738 Closing
|
||||
[Tue May 26 09:31:01 2026] 127.0.0.1:38278 Accepted
|
||||
[Tue May 26 09:31:02 2026] 127.0.0.1:38278 Closing
|
||||
[Tue May 26 09:31:03 2026] 127.0.0.1:38284 Accepted
|
||||
[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: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
|
||||
|
||||
@@ -133,8 +133,14 @@ class attachments implements attachments_i
|
||||
protected function fetchAttachmentRows(string $type, array $object_ids, array $options = []): array
|
||||
{
|
||||
$options = $this->normalizeAttachmentOptions($options);
|
||||
$rawType = trim($type, '`');
|
||||
$objectTypes = array_values(array_unique([
|
||||
$rawType,
|
||||
'`' . $rawType . '`',
|
||||
]));
|
||||
|
||||
return (new object_attachments_o())->getFieldsWhereIn([
|
||||
'object_type' => $type,
|
||||
'object_type' => $objectTypes,
|
||||
'object_id' => $object_ids,
|
||||
'deleted_at' => null
|
||||
], $options);
|
||||
|
||||
@@ -6,6 +6,7 @@ use classes\totp;
|
||||
use Exception;
|
||||
use interfaces\authentication_i;
|
||||
use objects\plate_scanners_o;
|
||||
use objects\subuser_grants_o;
|
||||
use objects\tokens_o;
|
||||
use objects\users_o;
|
||||
use objects\subusers_o;
|
||||
@@ -125,9 +126,13 @@ class authentication implements authentication_i
|
||||
public function validate_token(string $token): bool
|
||||
{
|
||||
// First: try validating as a classic user auth token
|
||||
$dbToken = (new tokens_o())->getToken($token);
|
||||
if ($dbToken && $dbToken->id) {
|
||||
return true;
|
||||
try {
|
||||
$dbToken = (new tokens_o())->getToken($token);
|
||||
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
|
||||
$subuser = (new subusers_o())->getSubuserBySessionToken($token);
|
||||
@@ -154,19 +159,17 @@ class authentication implements authentication_i
|
||||
// Strip the Bearer prefix
|
||||
$rawToken = str_replace('Bearer ', '', $rawToken);
|
||||
// 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
|
||||
if (!$token->id) {
|
||||
return false;
|
||||
}
|
||||
if ($token->type->value() === "AUTH_TOKEN_SUBUSER") {
|
||||
// Get the customer number from the headers
|
||||
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);
|
||||
if ($token->type->value() !== 'AUTH_TOKEN') {
|
||||
return false;
|
||||
}
|
||||
// Get the user from the database
|
||||
$user = (new users_o())->getUserById($token->user_id->value());
|
||||
|
||||
@@ -100,6 +100,38 @@ class coolify_api_client
|
||||
}
|
||||
|
||||
public function updateServiceEnvsBulk(string $uuid, array $env): array
|
||||
{
|
||||
if ($env === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->request('PATCH', '/services/' . rawurlencode($uuid) . '/envs/bulk', [
|
||||
'data' => self::bulkEnvData($env),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateApplicationEnvsBulk(string $uuid, array $env): array
|
||||
{
|
||||
if ($env === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->request('PATCH', '/applications/' . rawurlencode($uuid) . '/envs/bulk', [
|
||||
'data' => self::bulkEnvData($env),
|
||||
]);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$data = [];
|
||||
foreach ($env as $key => $value) {
|
||||
@@ -113,11 +145,7 @@ class coolify_api_client
|
||||
];
|
||||
}
|
||||
|
||||
if ($data === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->request('PATCH', '/services/' . rawurlencode($uuid) . '/envs/bulk', ['data' => $data]);
|
||||
return $data;
|
||||
}
|
||||
|
||||
public function deployResource(string $uuid, bool $force = false): array
|
||||
@@ -141,6 +169,11 @@ class coolify_api_client
|
||||
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
|
||||
{
|
||||
return $this->request('DELETE', '/services/' . rawurlencode($uuid));
|
||||
|
||||
@@ -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
|
||||
{
|
||||
$channelSlug = trim((string)($target['channel_slug'] ?? 'gateway'));
|
||||
@@ -1926,13 +2049,14 @@ class coolify_manager
|
||||
], $actorUserId);
|
||||
}
|
||||
|
||||
$deployment = $releaseManager->startDeployment([
|
||||
$sourceCommitSha = trim((string)($sourceTarget['latest_deployment_commit_sha'] ?? ''));
|
||||
$deploymentInput = [
|
||||
'target_id' => (int)($deploymentTarget['id'] ?? 0),
|
||||
'channel_id' => (int)$sourceTarget['channel_id'],
|
||||
'app' => $app,
|
||||
'repository' => (string)($sourceTarget['repository'] ?? ''),
|
||||
'branch' => (string)($sourceTarget['branch'] ?? 'master'),
|
||||
'commit_mode' => 'latest',
|
||||
'commit_mode' => $sourceCommitSha === '' ? 'latest' : 'specific',
|
||||
'version_label' => $this->gatewayRouteProvisionVersionLabel($sourceTarget),
|
||||
'deployed_url' => $sourcePublicUrl,
|
||||
'metadata' => [
|
||||
@@ -1942,7 +2066,11 @@ class coolify_manager
|
||||
'server_uuid' => $serverUuid,
|
||||
'app' => $app,
|
||||
],
|
||||
], $actorUserId);
|
||||
];
|
||||
if ($sourceCommitSha !== '') {
|
||||
$deploymentInput['commit_sha'] = $sourceCommitSha;
|
||||
}
|
||||
$deployment = $releaseManager->startDeployment($deploymentInput, $actorUserId);
|
||||
|
||||
if ((string)($deployment['status'] ?? '') !== 'deployed') {
|
||||
$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
|
||||
{
|
||||
unset($config['token']);
|
||||
|
||||
@@ -154,6 +154,12 @@ class coolify_schema_bootstrap
|
||||
self::ensureModuleConfigDefault('Coolify', 'hetzner_cloud_api_token', '', 'string');
|
||||
self::ensureModuleConfigDefault('Coolify', 'public_gateway_host', 'api-v2.truckwash.io', '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('node2.truckwash.io', '65.21.214.30', 20);
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
class cors_policy
|
||||
{
|
||||
public const ALLOWED_HEADERS = 'Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma, *';
|
||||
public const ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS';
|
||||
public const EXPOSED_HEADERS = 'Server-Timing';
|
||||
public const MAX_AGE_SECONDS = '86400';
|
||||
|
||||
private const REQUIRED_ALLOWED_ORIGINS = [
|
||||
'https://truckwash.io',
|
||||
'https://www.truckwash.io',
|
||||
'https://api.truckwash.io',
|
||||
'https://api.truckwash.io:4433',
|
||||
'https://api-v2.truckwash.io',
|
||||
'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',
|
||||
'http://localhost:5174',
|
||||
'http://127.0.0.1:5173',
|
||||
'http://127.0.0.1:5174',
|
||||
];
|
||||
|
||||
public static function normalizeOrigin(?string $value): string
|
||||
{
|
||||
$value = trim((string)$value);
|
||||
if ($value === '' || $value === '*') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (preg_match('#^https?://#i', $value) !== 1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$parts = parse_url($value);
|
||||
if (!is_array($parts) || empty($parts['scheme']) || empty($parts['host'])) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$scheme = strtolower((string)$parts['scheme']);
|
||||
if (!in_array($scheme, ['http', 'https'], true)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$host = strtolower((string)$parts['host']);
|
||||
$port = isset($parts['port']) ? ':' . (int)$parts['port'] : '';
|
||||
|
||||
return $scheme . '://' . $host . $port;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public static function requiredAllowedOrigins(): array
|
||||
{
|
||||
return self::REQUIRED_ALLOWED_ORIGINS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public static function allowedOrigins(string $corsConfig): array
|
||||
{
|
||||
$origins = [];
|
||||
foreach (self::splitOrigins($corsConfig) as $configuredOrigin) {
|
||||
if ($configuredOrigin === '*') {
|
||||
return ['*'];
|
||||
}
|
||||
|
||||
$origin = self::normalizeOrigin($configuredOrigin);
|
||||
if ($origin !== '') {
|
||||
$origins[$origin] = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (self::REQUIRED_ALLOWED_ORIGINS as $requiredOrigin) {
|
||||
$origin = self::normalizeOrigin($requiredOrigin);
|
||||
if ($origin !== '') {
|
||||
$origins[$origin] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return array_keys($origins);
|
||||
}
|
||||
|
||||
public static function withRequiredOrigins(string $corsConfig): string
|
||||
{
|
||||
$allowedOrigins = self::allowedOrigins($corsConfig);
|
||||
if ($allowedOrigins === ['*']) {
|
||||
return '*';
|
||||
}
|
||||
|
||||
return implode(',', $allowedOrigins);
|
||||
}
|
||||
|
||||
public static function isOriginAllowed(?string $origin, string $corsConfig): bool
|
||||
{
|
||||
$origin = self::normalizeOrigin($origin);
|
||||
if ($origin === '' || $origin === '*') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$allowedOrigins = self::allowedOrigins($corsConfig);
|
||||
return in_array('*', $allowedOrigins, true) || in_array($origin, $allowedOrigins, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,string>
|
||||
*/
|
||||
public static function responseHeaders(?string $origin, string $corsConfig): array
|
||||
{
|
||||
$origin = self::normalizeOrigin($origin);
|
||||
if ($origin === '' || !self::isOriginAllowed($origin, $corsConfig)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'Access-Control-Allow-Origin' => $origin,
|
||||
'Access-Control-Allow-Credentials' => 'true',
|
||||
'Access-Control-Allow-Headers' => self::ALLOWED_HEADERS,
|
||||
'Access-Control-Allow-Methods' => self::ALLOWED_METHODS,
|
||||
'Access-Control-Expose-Headers' => self::EXPOSED_HEADERS,
|
||||
'Access-Control-Max-Age' => self::MAX_AGE_SECONDS,
|
||||
'Timing-Allow-Origin' => $origin,
|
||||
'Vary' => 'Origin',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{allowed:bool,status:int,headers:array<string,string>,body:string}
|
||||
*/
|
||||
public static function preflightResponse(?string $origin, string $corsConfig): array
|
||||
{
|
||||
$headers = self::responseHeaders($origin, $corsConfig);
|
||||
if ($headers === []) {
|
||||
return [
|
||||
'allowed' => false,
|
||||
'status' => 403,
|
||||
'headers' => ['Content-Type' => 'application/json'],
|
||||
'body' => json_encode(['success' => false, 'message' => 'CORS origin not allowed']) ?: '',
|
||||
];
|
||||
}
|
||||
|
||||
$headers['Content-Type'] = 'application/json';
|
||||
return [
|
||||
'allowed' => true,
|
||||
'status' => 200,
|
||||
'headers' => $headers,
|
||||
'body' => '',
|
||||
];
|
||||
}
|
||||
|
||||
public static function applyResponseHeaders(string $corsConfig, ?string $origin = null): bool
|
||||
{
|
||||
$headers = self::responseHeaders($origin ?? ($_SERVER['HTTP_ORIGIN'] ?? ''), $corsConfig);
|
||||
if ($headers === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
self::emitHeaders($headers);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,string> $headers
|
||||
*/
|
||||
public static function emitHeaders(array $headers): void
|
||||
{
|
||||
foreach ($headers as $name => $value) {
|
||||
header($name . ': ' . $value, strtolower((string)$name) !== 'vary');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,string>
|
||||
*/
|
||||
private static function splitOrigins(string $corsConfig): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
array_map('trim', explode(',', $corsConfig)),
|
||||
static fn(string $origin): bool => $origin !== ''
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ class economic_transfer_queue
|
||||
$transfer_type = $this->validateTransferType($transfer_type);
|
||||
$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) {
|
||||
$target_label = $this->buildTargetLabel($transfer_type, $payload);
|
||||
$this->logQueueEvent(
|
||||
@@ -135,6 +135,89 @@ class economic_transfer_queue
|
||||
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
|
||||
{
|
||||
global $db;
|
||||
@@ -179,7 +262,7 @@ class economic_transfer_queue
|
||||
ON d.queue_job_id = q.id
|
||||
AND d.user_id = $user_id
|
||||
AND d.dismissed_status = q.status
|
||||
WHERE 1 = 1
|
||||
WHERE q.created_by = $user_id
|
||||
$transfer_condition
|
||||
AND (
|
||||
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');
|
||||
}
|
||||
|
||||
$job = $this->getJobById($job_id);
|
||||
$job = $this->getJobByIdForUser($job_id, $user_id);
|
||||
if ($job === null) {
|
||||
throw new Exception('Queue job not found');
|
||||
}
|
||||
@@ -272,7 +355,8 @@ class economic_transfer_queue
|
||||
ON d.queue_job_id = q.id
|
||||
AND d.user_id = $user_id
|
||||
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
|
||||
AND d.queue_job_id IS NULL
|
||||
ON DUPLICATE KEY UPDATE dismissed_status = VALUES(dismissed_status), dismissed_at = NOW()";
|
||||
@@ -284,10 +368,24 @@ class economic_transfer_queue
|
||||
* @throws Exception
|
||||
*/
|
||||
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;
|
||||
|
||||
$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) {
|
||||
throw new Exception('Queue job not found');
|
||||
}
|
||||
@@ -298,19 +396,26 @@ class economic_transfer_queue
|
||||
throw new Exception('Queue job reached max retry attempts');
|
||||
}
|
||||
|
||||
$stmt = $db->prepare(
|
||||
"UPDATE economic_transfer_queue_jobs
|
||||
$sql = "UPDATE economic_transfer_queue_jobs
|
||||
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
|
||||
WHERE id = ? AND status = ?"
|
||||
);
|
||||
WHERE id = ? AND status = ?";
|
||||
if ($created_by !== null) {
|
||||
$sql .= " AND created_by = ?";
|
||||
}
|
||||
|
||||
$stmt = $db->prepare($sql);
|
||||
if (!$stmt) {
|
||||
throw new Exception('Failed to prepare retry statement');
|
||||
}
|
||||
|
||||
$queued = self::STATUS_QUEUED;
|
||||
$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();
|
||||
$affected = $stmt->affected_rows;
|
||||
$stmt->close();
|
||||
@@ -321,7 +426,9 @@ class economic_transfer_queue
|
||||
|
||||
$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) {
|
||||
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');
|
||||
}
|
||||
|
||||
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) {
|
||||
self::TYPE_ORDER_DRAFT_EXPORT, self::TYPE_ORDER_INVOICE_EXPORT => $this->findActiveJobByJsonNumericTarget(
|
||||
$transfer_type,
|
||||
'$.order_id',
|
||||
(int)($payload['order_id'] ?? 0)
|
||||
(int)($payload['order_id'] ?? 0),
|
||||
$created_by
|
||||
),
|
||||
self::TYPE_COLLECTED_INVOICE_EXPORT => $this->findActiveJobByJsonNumericTarget(
|
||||
$transfer_type,
|
||||
'$.collected_invoice_id',
|
||||
(int)($payload['collected_invoice_id'] ?? 0)
|
||||
(int)($payload['collected_invoice_id'] ?? 0),
|
||||
$created_by
|
||||
),
|
||||
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;
|
||||
|
||||
if ($target_value < 1) {
|
||||
$created_by = max(0, $created_by);
|
||||
if ($target_value < 1 || $created_by < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -741,6 +851,7 @@ class economic_transfer_queue
|
||||
WHERE transfer_type = ?
|
||||
AND status IN (?, ?)
|
||||
AND CAST(JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$json_path')) AS UNSIGNED) = ?
|
||||
AND created_by = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
@@ -750,7 +861,7 @@ class economic_transfer_queue
|
||||
|
||||
$queued = self::STATUS_QUEUED;
|
||||
$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()) {
|
||||
$stmt->close();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
if (self::isFakeDeliveryEnabled()) {
|
||||
self::$fake_deliveries[] = [
|
||||
self::recordFakeDelivery([
|
||||
'to' => $to,
|
||||
'recipient_name' => $recipient_name,
|
||||
'subject' => $subject,
|
||||
'message' => $message,
|
||||
'html' => $html,
|
||||
];
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -225,6 +225,72 @@ use Psr\Http\Client\ClientExceptionInterface;
|
||||
public static function resetFakeDeliveries(): void
|
||||
{
|
||||
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
|
||||
@@ -511,4 +577,47 @@ use Psr\Http\Client\ClientExceptionInterface;
|
||||
$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
|
||||
{
|
||||
$flags = $this->fetchActiveManualFlagsFromDb();
|
||||
|
||||
try {
|
||||
(new redis())->cache_invoice_period_manual_flags($flags);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
private function fetchActiveManualFlagsFromDb(): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
@@ -337,10 +347,7 @@ class invoice_period_flag_service
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
(new redis())->cache_invoice_period_manual_flags($flags);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
return $flags;
|
||||
}
|
||||
|
||||
private function formatStoredFlag(array $row): array
|
||||
@@ -388,15 +395,11 @@ class invoice_period_flag_service
|
||||
}
|
||||
|
||||
if (!is_array($flags)) {
|
||||
// Cache miss — warm on demand and re-fetch
|
||||
$this->warmManualFlagsCache();
|
||||
// Cache miss — read from the database and refresh Redis without hiding active flags.
|
||||
$flags = $this->fetchActiveManualFlagsFromDb();
|
||||
try {
|
||||
$flags = (new redis())->get_invoice_period_manual_flags();
|
||||
(new redis())->cache_invoice_period_manual_flags($flags);
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
if (!is_array($flags)) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,16 +528,12 @@ class invoice_period_flag_service
|
||||
try {
|
||||
$flags = (new redis())->get_invoice_period_automatic_flags($dateFrom, $dateTo);
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
$flags = null;
|
||||
}
|
||||
|
||||
if (!is_array($flags)) {
|
||||
// Cache miss — enqueue for warming on the next cron run
|
||||
try {
|
||||
(new redis())->enqueue_invoice_period_warming($dateFrom, $dateTo);
|
||||
} catch (Throwable) {
|
||||
}
|
||||
return [];
|
||||
$flags = $this->calculateAutomaticFlagsForPeriod($dateFrom, $dateTo);
|
||||
$this->cacheAutomaticFlagsForPeriod($dateFrom, $dateTo, $flags);
|
||||
}
|
||||
|
||||
if ($onlyCustomerNumbers === null) {
|
||||
@@ -549,17 +548,31 @@ class invoice_period_flag_service
|
||||
|
||||
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);
|
||||
$attributes = $this->getCustomerAttributes(null);
|
||||
|
||||
$flags = array_merge(
|
||||
return array_merge(
|
||||
$this->detectCustomerRuleViolations($rows, $attributes),
|
||||
$this->detectPriceMismatches($rows),
|
||||
$this->detectAbnormalQuantities($rows, $dateFrom, $dateTo),
|
||||
$this->detectVehicleTypeMismatches($rows, $dateFrom),
|
||||
$this->detectMissingXlVaskLinks($dateFrom, $dateTo, null)
|
||||
);
|
||||
}
|
||||
|
||||
private function cacheAutomaticFlagsForPeriod(string $dateFrom, string $dateTo, array $flags): void
|
||||
{
|
||||
try {
|
||||
(new redis())->cache_invoice_period_automatic_flags($dateFrom, $dateTo, $flags);
|
||||
} catch (Throwable) {
|
||||
@@ -603,14 +616,19 @@ class invoice_period_flag_service
|
||||
|
||||
private function getPeriodOrderItemRows(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers): array
|
||||
{
|
||||
[$dateFrom, $dateTo] = $this->normalizePeriodDateRange($dateFrom, $dateTo);
|
||||
try {
|
||||
$rows = (new redis())->get_invoice_period_order_item_rows($dateFrom, $dateTo);
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
$rows = null;
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -627,6 +645,7 @@ class invoice_period_flag_service
|
||||
|
||||
public function warmOrderItemRowsForPeriod(string $dateFrom, string $dateTo): void
|
||||
{
|
||||
[$dateFrom, $dateTo] = $this->normalizePeriodDateRange($dateFrom, $dateTo);
|
||||
$rows = $this->fetchOrderItemRowsFromDb($dateFrom, $dateTo);
|
||||
try {
|
||||
(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
|
||||
{
|
||||
global $db;
|
||||
@@ -1727,18 +1764,7 @@ class invoice_period_flag_service
|
||||
if ($currentVehicleType === '' || $expectedVehicleType === '') {
|
||||
return false;
|
||||
}
|
||||
if ($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) === [];
|
||||
return $currentVehicleType === $expectedVehicleType;
|
||||
}
|
||||
|
||||
private function normalizePrimaryVehicleProductName(string $productName): string
|
||||
|
||||
@@ -10,6 +10,22 @@ use licenseplaterecognizer\licenseplaterecognizer_c;
|
||||
|
||||
class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
{
|
||||
private const DEFAULT_API_URL = 'https://vs4sws0kg4sog4ssw8kwowk4.coolify.truckwash.dk';
|
||||
private const PLATE_READER_CONFIG_JSON = '{"mode":"fast","plates_per_vehicle":1,"zoom_in_vehicles":0}';
|
||||
private const RESULT_CACHE_CONTEXT = '{"config":{"mode":"fast","plates_per_vehicle":1,"zoom_in_vehicles":0},"regions":"dk,de,se,no"}';
|
||||
private const PLATE_READER_REGIONS = 'dk,de,se,no';
|
||||
private const DEFAULT_UPLOAD_FILE_NAME = 'license-plate.jpg';
|
||||
private const RUNTIME_CONFIG_CACHE_TTL_SECONDS = 15;
|
||||
private const RUNTIME_CONFIG_REDIS_CACHE_KEY = 'licenseplaterecognizer:runtime_config:v1';
|
||||
private const RESULT_CACHE_TTL_SECONDS = 10;
|
||||
private const RESULT_CACHE_REDIS_KEY_PREFIX = 'licenseplaterecognizer:result:v1:';
|
||||
private const PLATE_READER_CONNECT_TIMEOUT_MS = 1000;
|
||||
private const PLATE_READER_TOTAL_TIMEOUT_MS = 4500;
|
||||
/**
|
||||
* @var array<string, float>
|
||||
*/
|
||||
private array $last_timings = [];
|
||||
|
||||
/**
|
||||
* The configuration of the module
|
||||
* @var licenseplaterecognizer_c
|
||||
@@ -19,12 +35,25 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
* API URL
|
||||
* @var string
|
||||
*/
|
||||
private string $api_url = 'https://vs4sws0kg4sog4ssw8kwowk4.coolify.truckwash.dk'; // Default (cloud): 'https://api.platerecognizer.com'; (without /v1/plate-reader/)';
|
||||
private string $api_url;
|
||||
|
||||
/**
|
||||
* @var array{enabled: bool, api_key: string}|null
|
||||
*/
|
||||
private ?array $runtime_config = null;
|
||||
|
||||
public function __construct()
|
||||
/**
|
||||
* @var array{values: array{enabled: bool, api_key: string}, cached_at: float}|null
|
||||
*/
|
||||
private static ?array $runtime_config_cache = null;
|
||||
|
||||
public function __construct(bool $load_config = true, ?string $api_url = null)
|
||||
{
|
||||
$this->config = new licenseplaterecognizer_c();
|
||||
$this->api_url = self::normalizeApiUrl($api_url ?? self::configuredApiUrl());
|
||||
|
||||
if ($load_config) {
|
||||
$this->config = new licenseplaterecognizer_c();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +62,7 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
*/
|
||||
public function requireModuleEnabled(): void
|
||||
{
|
||||
if (!(bool)$this->config->enabled->getVariableValue()) {
|
||||
if (!$this->runtimeConfig()['enabled']) {
|
||||
throw new Exception('licenseplaterecognizer module is not enabled.');
|
||||
}
|
||||
}
|
||||
@@ -45,54 +74,516 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
*/
|
||||
public function licenseplaterecognizer(string $base64_image): array
|
||||
{
|
||||
$image_processor = new image_processor();
|
||||
|
||||
//ADD PARAMETER IN REQUEST LIKE regions
|
||||
$data = array(
|
||||
'upload' => $base64_image,
|
||||
//'regions' => 'dk' // Optional
|
||||
return $this->recognizePlate(
|
||||
fn () => $this->buildPlateReaderPayload($base64_image),
|
||||
fn () => $this->buildResultCacheKeyFromUploadString($base64_image)
|
||||
);
|
||||
}
|
||||
|
||||
// Prepare new cURL resource
|
||||
//$ch = curl_init('https://api.platerecognizer.com/v1/plate-reader/');
|
||||
$ch = curl_init($this->api_url . '/v1/plate-reader/');
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLINFO_HEADER_OUT, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
|
||||
curl_setopt($ch, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_2TLS);
|
||||
public function licenseplaterecognizerUpload(string $image_data, string $mime_type = 'image/jpeg'): array
|
||||
{
|
||||
return $this->recognizePlate(
|
||||
fn () => $this->buildPlateReaderPayloadFromUpload(
|
||||
$this->buildUploadValueFromBytes($image_data, $mime_type)
|
||||
),
|
||||
fn () => $this->buildResultCacheKeyFromBytes($image_data)
|
||||
);
|
||||
}
|
||||
|
||||
// Set HTTP Header for POST request
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
|
||||
"Authorization: Token " . $this->config->api_key->getVariableValue()
|
||||
public function licenseplaterecognizerUploadUncached(string $image_data, string $mime_type = 'image/jpeg'): array
|
||||
{
|
||||
return $this->recognizePlate(
|
||||
fn () => $this->buildPlateReaderPayloadFromUpload(
|
||||
$this->buildUploadValueFromBytes($image_data, $mime_type)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Submit the POST request and close cURL session handle
|
||||
$result = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
// Print the response from the server
|
||||
if ($result === false) {
|
||||
throw new Exception('Error in API request.');
|
||||
}
|
||||
public function licenseplaterecognizerUploadFile(string $image_path, string $mime_type = 'image/jpeg'): array
|
||||
{
|
||||
return $this->recognizePlate(
|
||||
fn () => $this->buildPlateReaderPayloadFromUpload(
|
||||
$this->buildUploadValueFromFile($image_path, $mime_type)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$response_data = json_decode($result, true);
|
||||
if (isset($response_data['results']) && count($response_data['results']) > 0) {
|
||||
return [
|
||||
'success' => true,
|
||||
'license_plate_number' => $response_data['results'][0]['plate'] ?? null,
|
||||
'confidence' => $response_data['results'][0]['score'] ?? null,
|
||||
'raw_response' => $response_data,
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function recognizePlate(callable $payload_factory, ?callable $result_cache_key_factory = null): array
|
||||
{
|
||||
$started_at = microtime(true);
|
||||
$this->last_timings = [];
|
||||
$result_cache = null;
|
||||
$result_cache_key = null;
|
||||
|
||||
try {
|
||||
$config_started_at = microtime(true);
|
||||
$runtime_config = $this->runtimeConfig();
|
||||
if (!$runtime_config['enabled']) {
|
||||
throw new Exception('licenseplaterecognizer module is not enabled.');
|
||||
}
|
||||
$api_key = $runtime_config['api_key'];
|
||||
$this->last_timings['config'] = $this->elapsedMs($config_started_at);
|
||||
|
||||
if ($result_cache_key_factory !== null) {
|
||||
$cache_started_at = microtime(true);
|
||||
try {
|
||||
$result_cache = $this->resultCacheStore();
|
||||
if ($result_cache !== null) {
|
||||
$result_cache_key = $result_cache_key_factory();
|
||||
if ($result_cache_key !== null) {
|
||||
$cached_result = $this->readRecognitionResultCache($result_cache, $result_cache_key);
|
||||
if ($cached_result !== null) {
|
||||
$this->last_timings['cache_hit'] = 1;
|
||||
return $cached_result;
|
||||
}
|
||||
}
|
||||
}
|
||||
$this->last_timings['cache_miss'] = 1;
|
||||
} finally {
|
||||
$this->last_timings['cache'] = $this->elapsedMs($cache_started_at);
|
||||
}
|
||||
}
|
||||
|
||||
$payload_started_at = microtime(true);
|
||||
$data = $payload_factory();
|
||||
$this->last_timings['payload'] = $this->elapsedMs($payload_started_at);
|
||||
|
||||
$ch = curl_init($this->api_url . '/v1/plate-reader/');
|
||||
if (!$ch instanceof \CurlHandle) {
|
||||
throw new Exception('Error initializing API request.');
|
||||
}
|
||||
|
||||
$curl_options = [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $data,
|
||||
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS,
|
||||
CURLOPT_CONNECTTIMEOUT_MS => self::PLATE_READER_CONNECT_TIMEOUT_MS,
|
||||
CURLOPT_TIMEOUT_MS => self::PLATE_READER_TOTAL_TIMEOUT_MS,
|
||||
CURLOPT_NOSIGNAL => true,
|
||||
CURLOPT_NOPROGRESS => false,
|
||||
CURLOPT_XFERINFOFUNCTION => self::clientDisconnectAbortCallback(),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
"Authorization: Token " . $api_key,
|
||||
'Expect:',
|
||||
],
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
if (defined('CURLOPT_TCP_NODELAY')) {
|
||||
$curl_options[(int)constant('CURLOPT_TCP_NODELAY')] = true;
|
||||
}
|
||||
curl_setopt_array($ch, $curl_options);
|
||||
|
||||
// Submit the POST request and close cURL session handle
|
||||
$upstream_started_at = microtime(true);
|
||||
$result = curl_exec($ch);
|
||||
$this->last_timings['upstream'] = $this->elapsedMs($upstream_started_at);
|
||||
$this->recordCurlTimings($ch);
|
||||
curl_close($ch);
|
||||
|
||||
// Print the response from the server
|
||||
if ($result === false) {
|
||||
throw new Exception('Error in API request.');
|
||||
}
|
||||
|
||||
$parse_started_at = microtime(true);
|
||||
$response_data = json_decode($result, true);
|
||||
$this->last_timings['parse'] = $this->elapsedMs($parse_started_at);
|
||||
$this->recordResponseTimings($response_data);
|
||||
|
||||
if (isset($response_data['results']) && count($response_data['results']) > 0) {
|
||||
$recognized_result = [
|
||||
'success' => true,
|
||||
'license_plate_number' => $response_data['results'][0]['plate'] ?? null,
|
||||
'confidence' => $response_data['results'][0]['score'] ?? null,
|
||||
];
|
||||
|
||||
$this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result);
|
||||
|
||||
return $recognized_result;
|
||||
}
|
||||
|
||||
$recognized_result = [
|
||||
'success' => false,
|
||||
'message' => 'No license plate detected.',
|
||||
'raw_response' => $response_data,
|
||||
];
|
||||
$this->writeRecognitionResultCache($result_cache, $result_cache_key, $recognized_result);
|
||||
|
||||
return $recognized_result;
|
||||
} finally {
|
||||
$this->last_timings['total'] = $this->elapsedMs($started_at);
|
||||
}
|
||||
}
|
||||
|
||||
private static function clientDisconnectAbortCallback(): callable
|
||||
{
|
||||
return static function (): int {
|
||||
return connection_aborted() ? 1 : 0;
|
||||
};
|
||||
}
|
||||
|
||||
public function getLastTimings(): array
|
||||
{
|
||||
return $this->last_timings;
|
||||
}
|
||||
|
||||
private function elapsedMs(float $started_at): float
|
||||
{
|
||||
return (microtime(true) - $started_at) * 1000;
|
||||
}
|
||||
|
||||
private static function configuredApiUrl(): string
|
||||
{
|
||||
$configured = getenv('PLATE_RECOGNIZER_API_URL');
|
||||
if ($configured === false || trim((string)$configured) === '') {
|
||||
$configured = $_ENV['PLATE_RECOGNIZER_API_URL'] ?? $_SERVER['PLATE_RECOGNIZER_API_URL'] ?? self::DEFAULT_API_URL;
|
||||
}
|
||||
|
||||
return (string)$configured;
|
||||
}
|
||||
|
||||
private static function normalizeApiUrl(string $api_url): string
|
||||
{
|
||||
$api_url = trim($api_url);
|
||||
if ($api_url === '') {
|
||||
return self::DEFAULT_API_URL;
|
||||
}
|
||||
|
||||
return rtrim($api_url, '/');
|
||||
}
|
||||
|
||||
private function recordCurlTimings(\CurlHandle $curl_handle): void
|
||||
{
|
||||
$mapping = [
|
||||
CURLINFO_NAMELOOKUP_TIME => 'upstream_dns',
|
||||
CURLINFO_CONNECT_TIME => 'upstream_connect',
|
||||
CURLINFO_APPCONNECT_TIME => 'upstream_tls',
|
||||
CURLINFO_PRETRANSFER_TIME => 'upstream_pretransfer',
|
||||
CURLINFO_STARTTRANSFER_TIME => 'upstream_ttfb',
|
||||
CURLINFO_TOTAL_TIME => 'upstream_total',
|
||||
];
|
||||
|
||||
foreach ($mapping as $curl_info_option => $timing_key) {
|
||||
$value = curl_getinfo($curl_handle, $curl_info_option);
|
||||
if (!is_numeric($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->last_timings[$timing_key] = max(0, (float)$value * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
private function recordResponseTimings(mixed $response_data): void
|
||||
{
|
||||
if (!is_array($response_data) || !isset($response_data['processing_time']) || !is_numeric($response_data['processing_time'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->last_timings['upstream_processing'] = max(0, (float)$response_data['processing_time']);
|
||||
}
|
||||
|
||||
private function buildResultCacheKeyFromUploadString(string $base64_image): string
|
||||
{
|
||||
$base64_image = trim($base64_image);
|
||||
if (preg_match('/^data:image\/[a-zA-Z0-9.+-]+;base64,(.*)$/s', $base64_image, $matches) === 1) {
|
||||
$image_data = base64_decode((string)$matches[1], true);
|
||||
if (is_string($image_data)) {
|
||||
return $this->buildResultCacheKeyFromBytes($image_data);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->buildResultCacheKeyFromBytes($base64_image);
|
||||
}
|
||||
|
||||
private function buildResultCacheKeyFromBytes(string $image_data): string
|
||||
{
|
||||
$context = hash_init('sha256');
|
||||
hash_update($context, $this->resultCacheContext());
|
||||
hash_update($context, "\0");
|
||||
hash_update($context, $image_data);
|
||||
|
||||
return self::RESULT_CACHE_REDIS_KEY_PREFIX . hash_final($context);
|
||||
}
|
||||
|
||||
private function resultCacheContext(): string
|
||||
{
|
||||
return self::RESULT_CACHE_CONTEXT;
|
||||
}
|
||||
|
||||
protected function resultCacheStore(): ?object
|
||||
{
|
||||
return $this->runtimeConfigCacheStore();
|
||||
}
|
||||
|
||||
private function readRecognitionResultCache(?object $cache, ?string $key): ?array
|
||||
{
|
||||
if ($cache === null || $key === null || !method_exists($cache, 'get')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$cached = $cache->get($key);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!is_string($cached) || trim($cached) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($cached, true);
|
||||
if (!is_array($decoded) || !array_key_exists('success', $decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function writeRecognitionResultCache(?object $cache, ?string $key, array $result): void
|
||||
{
|
||||
if ($cache === null || $key === null || !method_exists($cache, 'setEx')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$encoded = json_encode($result, JSON_UNESCAPED_SLASHES);
|
||||
if (is_string($encoded)) {
|
||||
$cache->setEx($key, $encoded, self::RESULT_CACHE_TTL_SECONDS);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Scanner result cache is best-effort; Plate Recognizer remains the source of truth.
|
||||
}
|
||||
}
|
||||
|
||||
protected function buildPlateReaderPayload(string $base64_image): array
|
||||
{
|
||||
return $this->buildPlateReaderPayloadFromUpload($this->buildUploadValue($base64_image));
|
||||
}
|
||||
|
||||
protected function buildPlateReaderPayloadFromUpload(string|\CURLFile|\CURLStringFile $upload): array
|
||||
{
|
||||
return [
|
||||
'upload' => $upload,
|
||||
'config' => self::PLATE_READER_CONFIG_JSON,
|
||||
'regions' => self::PLATE_READER_REGIONS,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildUploadValue(string $base64_image): string|\CURLStringFile
|
||||
{
|
||||
$base64_image = trim($base64_image);
|
||||
if (preg_match('/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.*)$/s', $base64_image, $matches) !== 1) {
|
||||
return $base64_image;
|
||||
}
|
||||
|
||||
$image_data = base64_decode((string)$matches[2], true);
|
||||
if ($image_data === false || !class_exists(\CURLStringFile::class)) {
|
||||
return (string)$matches[2];
|
||||
}
|
||||
|
||||
return new \CURLStringFile($image_data, self::DEFAULT_UPLOAD_FILE_NAME, (string)$matches[1]);
|
||||
}
|
||||
|
||||
private function buildUploadValueFromBytes(string $image_data, string $mime_type): string|\CURLStringFile
|
||||
{
|
||||
$mime_type = trim($mime_type) !== '' ? trim($mime_type) : 'image/jpeg';
|
||||
if (!str_starts_with($mime_type, 'image/')) {
|
||||
$mime_type = 'image/jpeg';
|
||||
}
|
||||
|
||||
if (!class_exists(\CURLStringFile::class)) {
|
||||
return $image_data;
|
||||
}
|
||||
|
||||
return new \CURLStringFile($image_data, self::DEFAULT_UPLOAD_FILE_NAME, $mime_type);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function buildUploadValueFromFile(string $image_path, string $mime_type): \CURLFile
|
||||
{
|
||||
$image_path = trim($image_path);
|
||||
$mime_type = trim($mime_type) !== '' ? trim($mime_type) : 'image/jpeg';
|
||||
if (!str_starts_with($mime_type, 'image/')) {
|
||||
$mime_type = 'image/jpeg';
|
||||
}
|
||||
|
||||
if ($image_path === '' || !is_file($image_path) || !class_exists(\CURLFile::class)) {
|
||||
throw new Exception('Image upload file is invalid.');
|
||||
}
|
||||
|
||||
return new \CURLFile($image_path, $mime_type, self::DEFAULT_UPLOAD_FILE_NAME);
|
||||
}
|
||||
|
||||
protected function runtimeConfig(): array
|
||||
{
|
||||
if ($this->runtime_config !== null) {
|
||||
return $this->runtime_config;
|
||||
}
|
||||
|
||||
if ($this->shouldUseSharedRuntimeConfigCache()) {
|
||||
$cached_config = self::getSharedRuntimeConfigCache();
|
||||
if ($cached_config !== null) {
|
||||
$this->runtime_config = $cached_config;
|
||||
|
||||
return $this->runtime_config;
|
||||
}
|
||||
|
||||
$cached_config = $this->readRuntimeConfigCacheStore();
|
||||
if ($cached_config !== null) {
|
||||
self::$runtime_config_cache = [
|
||||
'values' => $cached_config,
|
||||
'cached_at' => microtime(true),
|
||||
];
|
||||
$this->runtime_config = $cached_config;
|
||||
|
||||
return $this->runtime_config;
|
||||
}
|
||||
}
|
||||
|
||||
$values = $this->readRuntimeModuleConfig();
|
||||
|
||||
$this->runtime_config = [
|
||||
'enabled' => $this->parseModuleConfigBool($values['enabled'] ?? false),
|
||||
'api_key' => (string)($values['api_key'] ?? ''),
|
||||
];
|
||||
|
||||
if ($this->shouldUseSharedRuntimeConfigCache()) {
|
||||
self::$runtime_config_cache = [
|
||||
'values' => $this->runtime_config,
|
||||
'cached_at' => microtime(true),
|
||||
];
|
||||
$this->writeRuntimeConfigCacheStore($this->runtime_config);
|
||||
}
|
||||
|
||||
return $this->runtime_config;
|
||||
}
|
||||
|
||||
protected function shouldUseSharedRuntimeConfigCache(): bool
|
||||
{
|
||||
return static::class === self::class;
|
||||
}
|
||||
|
||||
private static function getSharedRuntimeConfigCache(): ?array
|
||||
{
|
||||
if (self::$runtime_config_cache === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cache_age_seconds = microtime(true) - self::$runtime_config_cache['cached_at'];
|
||||
if ($cache_age_seconds > self::RUNTIME_CONFIG_CACHE_TTL_SECONDS) {
|
||||
self::$runtime_config_cache = null;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::$runtime_config_cache['values'];
|
||||
}
|
||||
|
||||
protected function runtimeConfigCacheStore(): ?object
|
||||
{
|
||||
return defined('redis') ? constant('redis') : null;
|
||||
}
|
||||
|
||||
private function readRuntimeConfigCacheStore(): ?array
|
||||
{
|
||||
$cache = $this->runtimeConfigCacheStore();
|
||||
if ($cache === null || !method_exists($cache, 'get')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$cached = $cache->get(self::RUNTIME_CONFIG_REDIS_CACHE_KEY);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!is_string($cached) || trim($cached) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($cached, true);
|
||||
if (!is_array($decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!array_key_exists('enabled', $decoded) || !array_key_exists('api_key', $decoded)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => $this->parseModuleConfigBool($decoded['enabled']),
|
||||
'api_key' => (string)$decoded['api_key'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{enabled: bool, api_key: string} $config
|
||||
*/
|
||||
private function writeRuntimeConfigCacheStore(array $config): void
|
||||
{
|
||||
$cache = $this->runtimeConfigCacheStore();
|
||||
if ($cache === null || !method_exists($cache, 'setEx')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$encoded = json_encode($config, JSON_UNESCAPED_SLASHES);
|
||||
if (is_string($encoded)) {
|
||||
$cache->setEx(self::RUNTIME_CONFIG_REDIS_CACHE_KEY, $encoded, self::RUNTIME_CONFIG_CACHE_TTL_SECONDS);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Scanner config cache is best-effort; DB remains the source of truth.
|
||||
}
|
||||
}
|
||||
|
||||
private function parseModuleConfigBool(mixed $value): bool
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return (int)$value === 1;
|
||||
}
|
||||
|
||||
return strtolower(trim((string)$value)) === 'true';
|
||||
}
|
||||
|
||||
protected function readRuntimeModuleConfig(): array
|
||||
{
|
||||
global $db;
|
||||
|
||||
if ($db instanceof db) {
|
||||
$module = $db->escape_string('licenseplaterecognizer');
|
||||
$result = $db->query("SELECT variable, value FROM module_config WHERE module = '$module' AND variable IN ('enabled', 'api_key')");
|
||||
$values = [];
|
||||
|
||||
if ($result instanceof \mysqli_result) {
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$variable = (string)($row['variable'] ?? '');
|
||||
if ($variable !== '') {
|
||||
$values[$variable] = (string)($row['value'] ?? '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
if (!isset($this->config)) {
|
||||
$this->config = new licenseplaterecognizer_c();
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => (string)$this->config->enabled->getVariableValue(),
|
||||
'api_key' => (string)$this->config->api_key->getVariableValue(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
* @throws Exception If the module is not enabled or if there is an error in the API request
|
||||
@@ -100,8 +591,8 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
*/
|
||||
public function get_usage(): licenseplaterecognizer_info
|
||||
{
|
||||
// Require the module to be enabled
|
||||
$this->requireModuleEnabled();
|
||||
$api_key = $this->runtimeConfig()['api_key'];
|
||||
$curl = curl_init();
|
||||
curl_setopt_array($curl, array(
|
||||
CURLOPT_URL => $this->api_url . '/info/',
|
||||
@@ -112,9 +603,9 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS,
|
||||
CURLOPT_CUSTOMREQUEST => 'GET',
|
||||
CURLOPT_HTTPHEADER => array(
|
||||
'Authorization: Token ' . $this->config->api_key->getVariableValue()
|
||||
),
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Token ' . $api_key,
|
||||
],
|
||||
));
|
||||
$response = curl_exec($curl);
|
||||
curl_close($curl);
|
||||
@@ -124,4 +615,4 @@ class licenseplaterecognizer implements licenseplaterecognizer_i
|
||||
}
|
||||
return new licenseplaterecognizer_info($response_data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,18 +340,49 @@ class n8n implements n8n_i
|
||||
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());
|
||||
if ($baseUrl === '') {
|
||||
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, '/');
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -46,7 +46,7 @@ class order_reference_suggestions_service
|
||||
$rows = [
|
||||
...$this->fetchBookingRows($departmentId, $search),
|
||||
...$this->fetchOrderRows($departmentId, $search),
|
||||
...$this->fetchVehicleRows($customerId, $plates, $search),
|
||||
...$this->fetchVehicleRows($departmentId, $customerId, $plates, $search),
|
||||
];
|
||||
|
||||
$suggestions = $this->aggregateRows($rows, $search, $customerId, $plates);
|
||||
@@ -137,7 +137,7 @@ class order_reference_suggestions_service
|
||||
* @param array<int, string> $plates
|
||||
* @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 = [];
|
||||
$params = [];
|
||||
@@ -171,6 +171,10 @@ class order_reference_suggestions_service
|
||||
$params['search'] = '%' . $this->lower($search) . '%';
|
||||
}
|
||||
|
||||
$where[] = $this->vehicleDepartmentAccessPredicate();
|
||||
$params['orders_department_id'] = $departmentId;
|
||||
$params['bookings_department_id'] = $departmentId;
|
||||
|
||||
$sql = "SELECT
|
||||
'vehicle' AS source,
|
||||
id AS origin_id,
|
||||
@@ -190,6 +194,41 @@ class order_reference_suggestions_service
|
||||
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
|
||||
* @return array<int, array<string, mixed>>
|
||||
|
||||
@@ -58,6 +58,9 @@ class orders_schema_bootstrap
|
||||
|| !self::columnExists($db, 'orders', 'booking_id')
|
||||
|| !self::columnExists($db, 'orders', '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;
|
||||
}
|
||||
@@ -65,6 +68,9 @@ class orders_schema_bootstrap
|
||||
$db->query(
|
||||
"UPDATE orders o
|
||||
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
|
||||
WHERE o.booking_id IS NOT NULL
|
||||
AND o.booking_id > 0
|
||||
|
||||
@@ -47,8 +47,12 @@ class pdf_store implements minio_pdfs_i
|
||||
*/
|
||||
public function download(string $file): string
|
||||
{
|
||||
if ($this->shouldUseLocalTestStorage()) {
|
||||
return $this->getLocalTestObjectPath($file);
|
||||
}
|
||||
|
||||
$path = '/tmp/' . $file;
|
||||
$result = self::getS3Client()->getObject([
|
||||
self::getS3Client()->getObject([
|
||||
'Bucket' => self::getBucket(),
|
||||
'Key' => $file,
|
||||
'SaveAs' => $path
|
||||
|
||||
@@ -392,7 +392,19 @@ class redis implements redis_i
|
||||
|
||||
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
|
||||
@@ -512,7 +524,11 @@ class redis implements redis_i
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -118,6 +118,35 @@ class release_manager_schema_bootstrap
|
||||
INDEX idx_release_targets_coolify (coolify_instance_id, coolify_service_uuid)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS release_auto_sync_events (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
channel_id BIGINT UNSIGNED NOT NULL,
|
||||
app VARCHAR(16) NOT NULL,
|
||||
repository VARCHAR(255) NOT NULL,
|
||||
branch VARCHAR(128) NOT NULL,
|
||||
commit_sha VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
source VARCHAR(64) NULL,
|
||||
workflow_url VARCHAR(512) NULL,
|
||||
gate_operation_id BIGINT UNSIGNED NULL,
|
||||
sync_operation_id BIGINT UNSIGNED NULL,
|
||||
deployment_id BIGINT UNSIGNED NULL,
|
||||
error_message TEXT NULL,
|
||||
metadata_json LONGTEXT NULL,
|
||||
received_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
gate_passed_at DATETIME NULL,
|
||||
synced_at DATETIME NULL,
|
||||
promoted_at DATETIME NULL,
|
||||
failed_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_release_auto_sync_event (channel_id, app, repository, branch, commit_sha),
|
||||
INDEX idx_release_auto_sync_channel_status (channel_id, status, updated_at),
|
||||
INDEX idx_release_auto_sync_gate (gate_operation_id),
|
||||
INDEX idx_release_auto_sync_sync (sync_operation_id),
|
||||
INDEX idx_release_auto_sync_deployment (deployment_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS release_service_sets (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
channel_id BIGINT UNSIGNED NULL,
|
||||
@@ -157,6 +186,7 @@ class release_manager_schema_bootstrap
|
||||
bundle_id BIGINT UNSIGNED NULL,
|
||||
deployment_kind VARCHAR(32) NOT NULL DEFAULT 'single_app',
|
||||
app VARCHAR(16) NOT NULL,
|
||||
active_channel_app_key VARCHAR(96) NULL,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'coolify',
|
||||
repository VARCHAR(255) NULL,
|
||||
branch VARCHAR(128) NULL,
|
||||
@@ -297,6 +327,47 @@ class release_manager_schema_bootstrap
|
||||
INDEX idx_release_module_health_channel (channel_id, module_key, checked_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS release_operation_runs (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
operation_type VARCHAR(64) NOT NULL,
|
||||
subject_type VARCHAR(64) NULL,
|
||||
subject_id VARCHAR(128) NULL,
|
||||
channel_id BIGINT UNSIGNED NULL,
|
||||
app VARCHAR(16) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'queued',
|
||||
title VARCHAR(255) NULL,
|
||||
summary TEXT NULL,
|
||||
solution_hint TEXT NULL,
|
||||
actor_user_id INT NULL,
|
||||
context_json LONGTEXT NULL,
|
||||
started_at DATETIME NULL,
|
||||
completed_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_release_operation_runs_channel (channel_id, created_at),
|
||||
INDEX idx_release_operation_runs_subject (subject_type, subject_id, created_at),
|
||||
INDEX idx_release_operation_runs_type_status (operation_type, status),
|
||||
INDEX idx_release_operation_runs_created (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS release_operation_steps (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
operation_run_id BIGINT UNSIGNED NOT NULL,
|
||||
step_key VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'queued',
|
||||
message TEXT NULL,
|
||||
diagnostic TEXT NULL,
|
||||
solution_hint TEXT NULL,
|
||||
context_json LONGTEXT NULL,
|
||||
started_at DATETIME NULL,
|
||||
completed_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_release_operation_steps_run (operation_run_id, id),
|
||||
INDEX idx_release_operation_steps_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS release_audit_logs (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
channel_id BIGINT UNSIGNED NULL,
|
||||
@@ -321,6 +392,7 @@ class release_manager_schema_bootstrap
|
||||
self::ensureColumn('release_deployments', 'service_set_id', 'BIGINT UNSIGNED NULL AFTER version_id');
|
||||
self::ensureColumn('release_deployments', 'bundle_id', 'BIGINT UNSIGNED NULL AFTER service_set_id');
|
||||
self::ensureColumn('release_deployments', 'deployment_kind', "VARCHAR(32) NOT NULL DEFAULT 'single_app' AFTER bundle_id");
|
||||
self::ensureColumn('release_deployments', 'active_channel_app_key', 'VARCHAR(96) NULL AFTER app');
|
||||
self::ensureColumn('release_timeline_sessions', 'device_type', 'VARCHAR(16) NULL AFTER api_version_id');
|
||||
self::ensureColumn('release_timeline_sessions', 'browser_name', 'VARCHAR(64) NULL AFTER device_type');
|
||||
self::ensureColumn('release_timeline_sessions', 'browser_version', 'VARCHAR(64) NULL AFTER browser_name');
|
||||
@@ -336,9 +408,12 @@ class release_manager_schema_bootstrap
|
||||
self::ensureColumn('release_timeline_sessions', 'last_route_path', 'VARCHAR(255) NULL AFTER api_commit_sha');
|
||||
self::ensureIndex('release_timeline_sessions', 'idx_release_timeline_device', 'device_type');
|
||||
self::ensureIndex('release_timeline_sessions', 'idx_release_timeline_release', 'frontend_version_label, api_version_label');
|
||||
self::ensureUniqueIndex('release_deployments', 'uniq_release_deployments_active_channel_app', 'active_channel_app_key');
|
||||
|
||||
self::ensureModuleConfigDefault('ReleaseManager', 'enabled', 'true', 'bool');
|
||||
self::ensureModuleConfigDefault('ReleaseManager', 'github_webhook_secret', '', 'string');
|
||||
self::ensureModuleConfigDefault('ReleaseManager', 'release_gate_token', '', 'string');
|
||||
self::ensureModuleConfigDefault('ReleaseManager', 'release_gate_required_for_promotion', 'true', 'bool');
|
||||
self::ensureModuleConfigDefault('ReleaseManager', 'github_token', '', 'string');
|
||||
self::ensureModuleConfigDefault('ReleaseManager', 'github_api_url', 'https://api.github.com', 'string');
|
||||
self::ensureModuleConfigDefault('ReleaseManager', 'default_retention_days', '14', 'int');
|
||||
@@ -362,9 +437,12 @@ class release_manager_schema_bootstrap
|
||||
'release_versions',
|
||||
'release_channel_versions',
|
||||
'release_assignments',
|
||||
'release_auto_sync_events',
|
||||
'release_service_sets',
|
||||
'release_deployments',
|
||||
'release_bundles',
|
||||
'release_operation_runs',
|
||||
'release_operation_steps',
|
||||
'release_timeline_sessions',
|
||||
'release_timeline_events',
|
||||
] as $table) {
|
||||
@@ -457,4 +535,23 @@ class release_manager_schema_bootstrap
|
||||
|
||||
$db->query("ALTER TABLE `$table` ADD INDEX `$index` ($columns)");
|
||||
}
|
||||
|
||||
private static function ensureUniqueIndex(string $table, string $index, string $columns): void
|
||||
{
|
||||
global $db;
|
||||
|
||||
$table = preg_replace('/[^a-zA-Z0-9_]/', '', $table);
|
||||
$index = preg_replace('/[^a-zA-Z0-9_]/', '', $index);
|
||||
if ($table === '' || $index === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$indexSql = $db->escape_string($index);
|
||||
$result = $db->query("SHOW INDEX FROM `$table` WHERE Key_name = '$indexSql'");
|
||||
if ($result !== false && $result->num_rows > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db->query("ALTER TABLE `$table` ADD UNIQUE KEY `$index` ($columns)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ class selfserve_schema_bootstrap
|
||||
session_id INT NOT NULL,
|
||||
task_id INT NULL,
|
||||
task_text VARCHAR(255) NOT NULL,
|
||||
description VARCHAR(255) NULL,
|
||||
description TEXT NULL,
|
||||
services JSON NULL,
|
||||
buttons JSON NULL,
|
||||
dynamic_image_id INT NULL,
|
||||
@@ -139,6 +139,28 @@ class selfserve_schema_bootstrap
|
||||
UNIQUE KEY uniq_selfserve_vhw_department (department_id),
|
||||
INDEX idx_selfserve_vhw_dept_updated (department_id, updated_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
|
||||
"CREATE TABLE IF NOT EXISTS department_selfserve_path_confirmations (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
department_id INT NOT NULL,
|
||||
lane_id INT NULL,
|
||||
vehicle_type_id INT NULL,
|
||||
config_version_id INT NULL,
|
||||
config_source VARCHAR(32) NOT NULL DEFAULT 'draft',
|
||||
path_signature VARCHAR(128) NOT NULL,
|
||||
result_signature VARCHAR(128) NOT NULL,
|
||||
answers_json JSON NOT NULL,
|
||||
result_json JSON NOT NULL,
|
||||
scope_json JSON NULL,
|
||||
confirmed_by INT NULL,
|
||||
confirmed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
stale_reason VARCHAR(255) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP NULL DEFAULT NULL,
|
||||
INDEX idx_selfserve_path_conf_department_scope (department_id, lane_id, vehicle_type_id, config_version_id),
|
||||
INDEX idx_selfserve_path_conf_signature (department_id, config_version_id, path_signature)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
];
|
||||
|
||||
foreach ($queries as $sql) {
|
||||
@@ -175,6 +197,18 @@ class selfserve_schema_bootstrap
|
||||
'gate_ref_id',
|
||||
'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(
|
||||
'selfserve_wash_session_tasks',
|
||||
'dynamic_images_vehicle_type',
|
||||
@@ -217,4 +251,49 @@ class selfserve_schema_bootstrap
|
||||
}
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ class shelly implements shelly_i
|
||||
private const SHELLY_RATE_LIMIT_WAIT_TIMEOUT_SECONDS = 20;
|
||||
private const SHELLY_RATE_LIMIT_WINDOW_MILLISECONDS = 2000;
|
||||
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>>
|
||||
*/
|
||||
@@ -178,6 +180,9 @@ class shelly implements shelly_i
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'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
|
||||
$response = curl_exec($ch);
|
||||
// Get the status code
|
||||
@@ -224,6 +229,9 @@ class shelly implements shelly_i
|
||||
);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 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);
|
||||
$status_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
|
||||
@@ -33,7 +33,7 @@ class shelly_relay_inventory
|
||||
* @return array<int,array<string,mixed>>
|
||||
* @throws Exception
|
||||
*/
|
||||
public function listRelayOptions(): array
|
||||
public function listRelayOptions(bool $include_sensitive_network_details = false): array
|
||||
{
|
||||
$devices_status = $this->fetchOwnedDevicesStatus();
|
||||
$device_catalog = $this->fetchOwnedDeviceCatalog();
|
||||
@@ -49,7 +49,8 @@ class shelly_relay_inventory
|
||||
|
||||
$option = $this->buildRelayOption(
|
||||
$normalized_device,
|
||||
is_array($catalog_entry) ? $catalog_entry : null
|
||||
is_array($catalog_entry) ? $catalog_entry : null,
|
||||
$include_sensitive_network_details
|
||||
);
|
||||
if ($option === null) {
|
||||
continue;
|
||||
@@ -160,7 +161,11 @@ class shelly_relay_inventory
|
||||
* @param array<string,mixed> $device
|
||||
* @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)) {
|
||||
return null;
|
||||
@@ -203,9 +208,8 @@ class shelly_relay_inventory
|
||||
$online = $this->normalizeBoolean($catalog_entry['cloud_online'] ?? null);
|
||||
}
|
||||
$status_color = $this->extractStatusColor($online);
|
||||
$local_ip = $this->extractLocalIp($device, $catalog_entry);
|
||||
|
||||
return [
|
||||
$option = [
|
||||
'id' => $device_id,
|
||||
'name' => $this->buildRelayLabel(
|
||||
$device_type,
|
||||
@@ -223,10 +227,15 @@ class shelly_relay_inventory
|
||||
'device_generation' => $device_generation,
|
||||
'control_type' => $control_type,
|
||||
'control_name' => $control_name !== '' ? $control_name : null,
|
||||
'local_ip' => $local_ip,
|
||||
'status_color' => $status_color,
|
||||
'online' => $online,
|
||||
];
|
||||
|
||||
if ($include_sensitive_network_details) {
|
||||
$option['local_ip'] = $this->extractLocalIp($device, $catalog_entry);
|
||||
}
|
||||
|
||||
return $option;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,13 +6,25 @@ use GuzzleHttp\Client;
|
||||
use interfaces\notification_i;
|
||||
use objects\departments_o;
|
||||
use objects\users_o;
|
||||
use slack\slack_c;
|
||||
use traits\notification_t;
|
||||
|
||||
require_once WD . '/modules/slack/slack_c.php';
|
||||
|
||||
class slack implements notification_i
|
||||
{
|
||||
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
|
||||
@@ -122,7 +134,7 @@ class slack implements notification_i
|
||||
. "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;
|
||||
// Format the message if a module is provided
|
||||
@@ -132,4 +144,90 @@ class slack implements notification_i
|
||||
// Send the message to the slack 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'] ?? []));
|
||||
$ownOnlyTypes = $this->normalizeTypes((array)($options['own_only_types'] ?? []));
|
||||
$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'] ?? []);
|
||||
$permissionsCatalogOwn = (array)($options['permissions_catalog_own'] ?? []);
|
||||
$moduleConfigVisibility = (array)($options['module_config_visibility'] ?? []);
|
||||
@@ -107,6 +108,7 @@ class system_search_service
|
||||
'offset' => $offset,
|
||||
'own' => $ownCustomerNumber,
|
||||
'own_only' => $ownOnlyTypes,
|
||||
'dept' => $allowedDepartmentIds,
|
||||
'assoc' => $includeAssociations,
|
||||
'dbg' => $debugIntent,
|
||||
'ctx' => $this->permissionContextFingerprint($permissionsCatalogAll, $permissionsCatalogOwn, $moduleConfigVisibility),
|
||||
@@ -130,7 +132,8 @@ class system_search_service
|
||||
$ownCustomerNumber,
|
||||
$permissionsCatalogAll,
|
||||
$permissionsCatalogOwn,
|
||||
$moduleConfigVisibility
|
||||
$moduleConfigVisibility,
|
||||
$allowedDepartmentIds
|
||||
);
|
||||
|
||||
$intentAssociationHint = false;
|
||||
@@ -180,7 +183,8 @@ class system_search_service
|
||||
$ownCustomerNumber,
|
||||
$permissionsCatalogAll,
|
||||
$permissionsCatalogOwn,
|
||||
$moduleConfigVisibility
|
||||
$moduleConfigVisibility,
|
||||
$allowedDepartmentIds
|
||||
);
|
||||
} else {
|
||||
$intentMeta['status'] = 'fallback';
|
||||
@@ -207,25 +211,29 @@ class system_search_service
|
||||
$activeTypes,
|
||||
$this->associationEntityTypes()
|
||||
));
|
||||
foreach ($customerNumbers as $customerNumber) {
|
||||
$associated = $this->executeLexicalSearch(
|
||||
$associationTypes,
|
||||
[(string)$customerNumber],
|
||||
[],
|
||||
$ownOnlyTypes,
|
||||
$ownCustomerNumber,
|
||||
$permissionsCatalogAll,
|
||||
$permissionsCatalogOwn,
|
||||
$moduleConfigVisibility,
|
||||
[$customerNumber]
|
||||
);
|
||||
foreach ($associated as &$item) {
|
||||
if (!isset($item['association_reason'])) {
|
||||
$item['association_reason'] = 'customer:' . $customerNumber;
|
||||
$associationTypes = array_values(array_diff($associationTypes, $ownOnlyTypes));
|
||||
if (!empty($associationTypes)) {
|
||||
foreach ($customerNumbers as $customerNumber) {
|
||||
$associated = $this->executeLexicalSearch(
|
||||
$associationTypes,
|
||||
[(string)$customerNumber],
|
||||
[],
|
||||
$ownOnlyTypes,
|
||||
$ownCustomerNumber,
|
||||
$permissionsCatalogAll,
|
||||
$permissionsCatalogOwn,
|
||||
$moduleConfigVisibility,
|
||||
$allowedDepartmentIds,
|
||||
[$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<int, string> $permissionsCatalogOwn
|
||||
* @param array<string, bool> $moduleConfigVisibility
|
||||
* @param array<int, int> $allowedDepartmentIds
|
||||
* @param array<int, int> $forcedCustomerNumbers
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
@@ -316,6 +325,7 @@ class system_search_service
|
||||
array $permissionsCatalogAll,
|
||||
array $permissionsCatalogOwn,
|
||||
array $moduleConfigVisibility,
|
||||
array $allowedDepartmentIds = [],
|
||||
array $forcedCustomerNumbers = []
|
||||
): array {
|
||||
$results = [];
|
||||
@@ -334,6 +344,7 @@ class system_search_service
|
||||
$ownOnly,
|
||||
$ownCustomerNumber,
|
||||
$moduleConfigVisibility,
|
||||
$allowedDepartmentIds,
|
||||
$forcedCustomerNumbers
|
||||
);
|
||||
if (empty($rows)) {
|
||||
@@ -346,6 +357,7 @@ class system_search_service
|
||||
$permissionsCatalogAll,
|
||||
$permissionsCatalogOwn,
|
||||
$moduleConfigVisibility,
|
||||
$allowedDepartmentIds,
|
||||
$forcedCustomerNumbers
|
||||
);
|
||||
}
|
||||
@@ -359,6 +371,7 @@ class system_search_service
|
||||
$permissionsCatalogAll,
|
||||
$permissionsCatalogOwn,
|
||||
$moduleConfigVisibility,
|
||||
$allowedDepartmentIds,
|
||||
$forcedCustomerNumbers
|
||||
);
|
||||
}
|
||||
@@ -372,6 +385,7 @@ class system_search_service
|
||||
* @param array<string, string> $permissionsCatalogAll
|
||||
* @param array<int, string> $permissionsCatalogOwn
|
||||
* @param array<string, bool> $moduleConfigVisibility
|
||||
* @param array<int, int> $allowedDepartmentIds
|
||||
* @param array<int, int> $forcedCustomerNumbers
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
@@ -384,6 +398,7 @@ class system_search_service
|
||||
array $permissionsCatalogAll,
|
||||
array $permissionsCatalogOwn,
|
||||
array $moduleConfigVisibility,
|
||||
array $allowedDepartmentIds,
|
||||
array $forcedCustomerNumbers
|
||||
): array {
|
||||
if ($this->isGenericEntityType($entityType)) {
|
||||
@@ -393,6 +408,7 @@ class system_search_service
|
||||
$entityBoost,
|
||||
$ownOnly,
|
||||
$ownCustomerNumber,
|
||||
$allowedDepartmentIds,
|
||||
$forcedCustomerNumbers
|
||||
);
|
||||
}
|
||||
@@ -439,9 +455,24 @@ class system_search_service
|
||||
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<string, bool> $moduleConfigVisibility
|
||||
* @param array<int, int> $allowedDepartmentIds
|
||||
* @param array<int, int> $forcedCustomerNumbers
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
@@ -452,6 +483,7 @@ class system_search_service
|
||||
bool $ownOnly,
|
||||
?int $ownCustomerNumber,
|
||||
array $moduleConfigVisibility,
|
||||
array $allowedDepartmentIds,
|
||||
array $forcedCustomerNumbers
|
||||
): array {
|
||||
global $db;
|
||||
@@ -473,6 +505,9 @@ class system_search_service
|
||||
if (!empty($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);
|
||||
$rows = [];
|
||||
@@ -1488,6 +1523,7 @@ class system_search_service
|
||||
|
||||
/**
|
||||
* @param array<int, string> $terms
|
||||
* @param array<int, int> $allowedDepartmentIds
|
||||
* @param array<int, int> $forcedCustomerNumbers
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
@@ -1497,6 +1533,7 @@ class system_search_service
|
||||
int $entityBoost,
|
||||
bool $ownOnly,
|
||||
?int $ownCustomerNumber,
|
||||
array $allowedDepartmentIds = [],
|
||||
array $forcedCustomerNumbers = []
|
||||
): array {
|
||||
if (empty($terms)) {
|
||||
@@ -1602,7 +1639,9 @@ class system_search_service
|
||||
$customerNumbers,
|
||||
$customerField,
|
||||
$customerFieldMode,
|
||||
$fixedConditions
|
||||
$fixedConditions,
|
||||
$allowedDepartmentIds,
|
||||
$departmentField
|
||||
);
|
||||
|
||||
$this->primeCustomerContexts(array_values(array_unique(array_filter(
|
||||
@@ -1798,6 +1837,8 @@ class system_search_service
|
||||
* @param string|null $customerField
|
||||
* @param string $customerFieldMode
|
||||
* @param array<string, mixed> $fixedConditions
|
||||
* @param array<int, int> $departmentIds
|
||||
* @param string|null $departmentField
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function searchTable(
|
||||
@@ -1808,7 +1849,9 @@ class system_search_service
|
||||
array $customerNumbers = [],
|
||||
?string $customerField = null,
|
||||
string $customerFieldMode = 'default',
|
||||
array $fixedConditions = []
|
||||
array $fixedConditions = [],
|
||||
array $departmentIds = [],
|
||||
?string $departmentField = null
|
||||
): array {
|
||||
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 = [];
|
||||
foreach ($terms as $term) {
|
||||
$escaped = $db->escape_string($term);
|
||||
|
||||
@@ -6,6 +6,7 @@ require_once WD . '/modules/workfeed/workfeed_c.php';
|
||||
|
||||
use Exception;
|
||||
use interfaces\workfeed_i;
|
||||
use workfeed\config\workfeed_api_url_c;
|
||||
use workfeed\workfeed_c;
|
||||
|
||||
class workfeed implements workfeed_i
|
||||
@@ -84,7 +85,7 @@ class workfeed implements workfeed_i
|
||||
$companyId = $this->requireConfiguredCompanyId();
|
||||
|
||||
$url = $this->buildUrl(
|
||||
$this->config->api_url->getVariableValue(),
|
||||
workfeed_api_url_c::normalizeApiUrlForValidation((string)$this->config->api_url->getVariableValue()),
|
||||
'/companies/' . rawurlencode($companyId) . '/' . ltrim($path, '/'),
|
||||
$query
|
||||
);
|
||||
@@ -182,7 +183,10 @@ class workfeed implements workfeed_i
|
||||
private function requireConfiguredApiUrl(): void
|
||||
{
|
||||
$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.');
|
||||
}
|
||||
}
|
||||
@@ -210,15 +214,6 @@ class workfeed implements workfeed_i
|
||||
return $companyId;
|
||||
}
|
||||
|
||||
private function normalizeUrlForValidation(string $url): string
|
||||
{
|
||||
if (preg_match('#^https?://#i', $url)) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return 'https://' . ltrim($url, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"scripts": {
|
||||
"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:integration": "vendor/bin/pest --testsuite=Integration --colors=always",
|
||||
"test:api": [
|
||||
|
||||
@@ -19,6 +19,8 @@ use classes\slack as Slack;
|
||||
use classes\email as Email;
|
||||
use classes\gatewayapi as GatewayAPI;
|
||||
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\services\goals_progress_alert_renderer;
|
||||
use goals\helpers\goals_criteria_progress_alert_destination as Dest;
|
||||
@@ -33,6 +35,8 @@ use objects\users_o;
|
||||
use routes\moduleWeatherAPIRoute;
|
||||
|
||||
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.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),
|
||||
'only_current_step' => (bool)($variant['only_current_step'] ?? false),
|
||||
'vehicle_type' => $variant['vehicle_type'] ?? null,
|
||||
'dynamic_image_size' => getSelfServeDynamicImageSizeModeForCron(),
|
||||
];
|
||||
|
||||
$json = json_encode($cacheParams);
|
||||
@@ -962,16 +967,11 @@ function renderDynamicImageVariant(int $dynamicImageId, ?array $buttons, int $cu
|
||||
$image->current_step = max(0, $currentStep);
|
||||
$image->only_generate_current_step = $onlyCurrentStep;
|
||||
$image->setup();
|
||||
if (getSelfServeDynamicImageSizeModeForCron() === selfserve_dynamic_image_size_c::SIZE_RELEVANT) {
|
||||
$image->resizeToMaxWidth(DYNAMIC_IMAGE_RELEVANT_MAX_WIDTH);
|
||||
}
|
||||
|
||||
$dataUri = $image->exportAsBase64('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;
|
||||
return $image->exportBinary('png');
|
||||
} catch (Throwable $e) {
|
||||
warn('PreRenderDynamicImagesCron: render failed for dynamic_image_id=' . $dynamicImageId . ': ' . $e->getMessage());
|
||||
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
|
||||
* @return array<int|string>
|
||||
|
||||
@@ -5,6 +5,21 @@ $isPreview = $_GET['preview'] ?? false;
|
||||
// Remove query string if present
|
||||
$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;
|
||||
$isPDFStore = false;
|
||||
$isAttachment = false;
|
||||
@@ -40,20 +55,6 @@ if ($isPDF && $isPDFStore) {
|
||||
|
||||
// Check if the certificate exists
|
||||
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;
|
||||
//header('HTTP/1.1 404 Not Found');
|
||||
exit;
|
||||
@@ -118,4 +119,4 @@ if (!$isPDF) {
|
||||
// Delete the file from /tmp after sending it
|
||||
unlink($file_path);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,30 +10,18 @@ const WD = __DIR__;
|
||||
|
||||
require_once __DIR__ . '/vendor/autoload.php';
|
||||
require_once 'config.php';
|
||||
require_once __DIR__ . '/classes/cors_policy.php';
|
||||
|
||||
/** CORS */
|
||||
$corsAllowedHeaders = 'Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, Cache-Control, Pragma, *';
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||
$allowed_origins = array_map('trim', explode(',', (string)($CORS ?? '*')));
|
||||
if ($CORS === '*' || ($origin && in_array($origin, $allowed_origins))) {
|
||||
header("Access-Control-Allow-Origin: " . ($origin ?: '*'));
|
||||
header("Access-Control-Allow-Credentials: true");
|
||||
header("Access-Control-Allow-Headers: {$corsAllowedHeaders}");
|
||||
header("Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS");
|
||||
}
|
||||
|
||||
// OPTIONS requests are preflight requests for CORS, we can just return a 200 OK response
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||
if ($CORS === '*' || ($origin && in_array($origin, $allowed_origins))) {
|
||||
header("Access-Control-Allow-Origin: " . ($origin ?: '*'));
|
||||
header("Access-Control-Allow-Credentials: true");
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
||||
header("Access-Control-Allow-Headers: {$corsAllowedHeaders}");
|
||||
header('Content-Type: application/json');
|
||||
http_response_code(200);
|
||||
exit;
|
||||
}
|
||||
$preflight = \classes\cors_policy::preflightResponse($_SERVER['HTTP_ORIGIN'] ?? '', (string)($CORS ?? ''));
|
||||
\classes\cors_policy::emitHeaders($preflight['headers']);
|
||||
http_response_code($preflight['status']);
|
||||
echo $preflight['body'];
|
||||
exit;
|
||||
}
|
||||
\classes\cors_policy::applyResponseHeaders((string)($CORS ?? ''));
|
||||
/** Debug */
|
||||
if ($DEBUG) {
|
||||
ini_set('display_errors', 1);
|
||||
@@ -73,6 +61,17 @@ try {
|
||||
spl_autoload_register(function (string $class): void {
|
||||
$class = ltrim($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 {
|
||||
return class_exists($candidate, false)
|
||||
|| interface_exists($candidate, false)
|
||||
@@ -85,11 +84,13 @@ spl_autoload_register(function (string $class): void {
|
||||
try {
|
||||
$cached = redis->get($cache_key);
|
||||
if (is_string($cached) && $cached !== '' && is_file($cached)) {
|
||||
require_once $cached;
|
||||
if ($is_loaded($class)) {
|
||||
return;
|
||||
if ($isPathInside($cached, $wdReal)) {
|
||||
require_once $cached;
|
||||
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);
|
||||
} elseif (is_string($cached) && $cached !== '') {
|
||||
// Remove non-existing cached path to avoid repeated failed lookups.
|
||||
@@ -132,6 +133,18 @@ spl_autoload_register(function (string $class): void {
|
||||
$module_dirs = redis->get_array('autoload:module_dirs');
|
||||
} 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) {
|
||||
$module_dirs = array_filter(scandir($base . 'modules'), function($item) use ($base) {
|
||||
return $item !== '.' && $item !== '..' && is_dir($base . 'modules' . DIRECTORY_SEPARATOR . $item);
|
||||
@@ -249,11 +262,6 @@ if (php_sapi_name() === 'cli' || isset($_GET['internalCronCall'])) {
|
||||
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 ((preg_match('/\.(jpg|jpeg|png)$/', $_SERVER['REQUEST_URI']) || str_contains($_SERVER['REQUEST_URI'], '/files/'))) {
|
||||
require_once 'file_server.php';
|
||||
|
||||
@@ -12,10 +12,35 @@ interface licenseplaterecognizer_i extends universal_module_i
|
||||
* @return array An array containing the plate number and other relevant information.
|
||||
*/
|
||||
public function licenseplaterecognizer(string $base64_image): array;
|
||||
|
||||
/**
|
||||
* Get the plate number from raw uploaded image bytes.
|
||||
* @param string $image_data Raw uploaded image bytes.
|
||||
* @param string $mime_type The image MIME type.
|
||||
* @return array An array containing the plate number and other relevant information.
|
||||
*/
|
||||
public function licenseplaterecognizerUpload(string $image_data, string $mime_type = 'image/jpeg'): array;
|
||||
|
||||
/**
|
||||
* Get the plate number from raw uploaded image bytes without building an exact-result cache key.
|
||||
* @param string $image_data Raw uploaded image bytes.
|
||||
* @param string $mime_type The image MIME type.
|
||||
* @return array An array containing the plate number and other relevant information.
|
||||
*/
|
||||
public function licenseplaterecognizerUploadUncached(string $image_data, string $mime_type = 'image/jpeg'): array;
|
||||
|
||||
/**
|
||||
* Get the plate number from a PHP upload temp file without copying it into memory.
|
||||
* @param string $image_path The uploaded image temp-file path.
|
||||
* @param string $mime_type The image MIME type.
|
||||
* @return array An array containing the plate number and other relevant information.
|
||||
*/
|
||||
public function licenseplaterecognizerUploadFile(string $image_path, string $mime_type = 'image/jpeg'): array;
|
||||
|
||||
/**
|
||||
* Get usage information about the license plate recognizer module.
|
||||
* @returns licenseplaterecognizer_info An object containing usage statistics and information.
|
||||
* @see licenseplaterecognizer_info
|
||||
*/
|
||||
public function get_usage(): licenseplaterecognizer_info;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace attachments\helpers;
|
||||
class attachment_content
|
||||
{
|
||||
const OTHER_TYPE_WASH_CERTIFICATE = 'WASH_CERTIFICATE';
|
||||
const OTHER_TYPE_SELF_SERVE_WASH = 'SELF_SERVE_WASH';
|
||||
public ?string $image; // Used to store the attachment object name, in the attachment store.
|
||||
public ?string $document; // Used to store the attachment object name, in the attachment store.
|
||||
public ?attachment_relation $relation; // Used to store the attachment relation object.
|
||||
@@ -49,4 +50,4 @@ class attachment_content
|
||||
$this->relation = $relation;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
@@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
+25
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ class machine_1 extends dynamicimages_image
|
||||
const IMAGE_BUTTON_HIGHLIGHTED_GREEN = 'machine_1_button_highlighted_green.png';
|
||||
const BUTTON_RESET = 'reset';
|
||||
const BUTTON_START = 'start';
|
||||
const BUTTON_PROGRAM_PICKER = 'program_picker';
|
||||
// Thumb
|
||||
public int $thumb_position = 1; // 0-11 (default: 0 = up = 270 degrees)
|
||||
public int $thumb_size = 1550; // height and width of the thumb
|
||||
@@ -73,7 +74,6 @@ class machine_1 extends dynamicimages_image
|
||||
$this->drawAsset($this->getAsset(self::IMAGE_PANEL_BACKGROUND), 0, 0);
|
||||
$this->drawProgramWheel();
|
||||
$this->drawThumb();
|
||||
$this->drawStepThumb();
|
||||
$deferredStartButtons = $this->drawHighlightedButtonSequence();
|
||||
$this->drawAsset($this->getAsset(self::IMAGE_POWER_BUTTON), 0, 0);
|
||||
$this->drawDeferredHighlightedButtons($deferredStartButtons);
|
||||
@@ -198,7 +198,7 @@ class machine_1 extends dynamicimages_image
|
||||
if (is_string($button)) {
|
||||
$trimmed = trim($button);
|
||||
$specialButton = strtolower($trimmed);
|
||||
if ($specialButton === self::BUTTON_RESET || $specialButton === self::BUTTON_START) {
|
||||
if ($specialButton === self::BUTTON_RESET || $specialButton === self::BUTTON_START || $specialButton === self::BUTTON_PROGRAM_PICKER) {
|
||||
return $specialButton;
|
||||
}
|
||||
|
||||
@@ -255,6 +255,10 @@ class machine_1 extends dynamicimages_image
|
||||
|
||||
private function getHighlightedButtonCoordinates(int|string $button): ?array
|
||||
{
|
||||
if ($button === self::BUTTON_PROGRAM_PICKER) {
|
||||
return $this->getProgramPickerStepCoordinates();
|
||||
}
|
||||
|
||||
if ($button === self::BUTTON_RESET) {
|
||||
return [
|
||||
'x' => 1736,
|
||||
@@ -274,6 +278,25 @@ class machine_1 extends dynamicimages_image
|
||||
return is_int($button) ? $this->getRegularButtonCoordinates($button) : null;
|
||||
}
|
||||
|
||||
private function getProgramPickerStepCoordinates(): array
|
||||
{
|
||||
$thumbX = 650;
|
||||
$thumbY = 1290;
|
||||
$stepSize = 350;
|
||||
$baseThumb = $this->getAsset(self::IMAGE_WASH_PROGRAMS_THUMB)->resize($this->calculateThumbWidth($this->thumb_size), $this->thumb_size);
|
||||
$centerX = $thumbX + (int)floor($baseThumb->getWidth() / 2);
|
||||
$centerY = $thumbY + (int)floor($baseThumb->getHeight() / 2);
|
||||
$angle = $this->getRotationThumbPosition();
|
||||
$radius = ($this->thumb_size / 2) - ($stepSize / 2) - 150;
|
||||
$rad = deg2rad($angle);
|
||||
|
||||
return [
|
||||
'x' => $centerX + (int)round($radius * cos($rad)) - (int)floor($stepSize / 2),
|
||||
'y' => $centerY + (int)round($radius * sin($rad)) - (int)floor($stepSize / 2),
|
||||
'size' => $stepSize,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
|
||||
@@ -86,6 +86,14 @@ interface dynamicimages_image_i
|
||||
*/
|
||||
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.
|
||||
* Convenience wrapper for outputting binary image data.
|
||||
@@ -94,4 +102,4 @@ interface dynamicimages_image_i
|
||||
* @param int $quality Quality for lossy formats (0-100)
|
||||
*/
|
||||
public function servePicture(?string $format = null, int $quality = 90): void;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,6 +226,20 @@ trait dynamicimages_image_t
|
||||
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
|
||||
{
|
||||
$this->assertCanvasInitialized();
|
||||
@@ -307,20 +321,8 @@ trait dynamicimages_image_t
|
||||
*/
|
||||
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) {
|
||||
$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 'data:image/png;base64,' . base64_encode($blob);
|
||||
return 'data:image/png;base64,' . base64_encode($this->exportBinary($format, $quality));
|
||||
}
|
||||
|
||||
// Fallback: export first asset as-is
|
||||
@@ -341,6 +343,40 @@ trait dynamicimages_image_t
|
||||
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
|
||||
{
|
||||
foreach ($this->assets as $asset) {
|
||||
@@ -357,22 +393,18 @@ trait dynamicimages_image_t
|
||||
*/
|
||||
public function outputImage(?string $format = null, int $quality = 90): void
|
||||
{
|
||||
$dataUri = $this->exportAsBase64($format, $quality);
|
||||
// Extract mime type and base64 data
|
||||
if (preg_match('/^data:(image\/[a-zA-Z0-9+.-]+);base64,(.*)$/', $dataUri, $matches)) {
|
||||
$mimeType = $matches[1];
|
||||
$base64Data = $matches[2];
|
||||
// Decode base64 data
|
||||
$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;
|
||||
}
|
||||
$mimeType = 'image/png';
|
||||
if (!$this->image instanceof \Imagick && !empty($this->assets)) {
|
||||
$asset = $this->assets[0];
|
||||
$path = $asset->getPath();
|
||||
$imgInfo = is_readable($path) ? @getimagesize($path) : false;
|
||||
$mimeType = is_array($imgInfo) && isset($imgInfo['mime']) ? $imgInfo['mime'] : 'application/octet-stream';
|
||||
}
|
||||
$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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3426,7 +3426,7 @@ BASH;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->shellyRelayOptionsCache = (new shelly_relay_inventory())->listRelayOptions();
|
||||
$this->shellyRelayOptionsCache = (new shelly_relay_inventory())->listRelayOptions(true);
|
||||
} catch (\Throwable) {
|
||||
$this->shellyRelayOptionsCache = [];
|
||||
}
|
||||
@@ -4021,7 +4021,7 @@ BASH;
|
||||
{
|
||||
$configured = $this->configuredBrokerSharedSecret();
|
||||
if ($configured === '') {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
return $secret !== null && hash_equals($configured, trim($secret));
|
||||
@@ -4036,12 +4036,8 @@ BASH;
|
||||
$target = strtolower(trim((string)($options['target'] ?? 'all')));
|
||||
$target = in_array($target, ['internal', 'public', 'secret', 'all'], true) ? $target : 'all';
|
||||
|
||||
$internalUrl = $this->normalizeBrokerDiagnosticBaseUrl(
|
||||
array_key_exists('broker_url', $options) ? $options['broker_url'] : $this->configuredBrokerInternalUrl()
|
||||
);
|
||||
$publicConfigured = array_key_exists('public_broker_url', $options)
|
||||
? trim((string)$options['public_broker_url'])
|
||||
: $this->configuredPublicBrokerUrl();
|
||||
$internalUrl = $this->normalizeBrokerDiagnosticBaseUrl($this->configuredBrokerInternalUrl());
|
||||
$publicConfigured = $this->configuredPublicBrokerUrl();
|
||||
$publicUrl = $this->normalizeBrokerDiagnosticBaseUrl(
|
||||
$publicConfigured !== '' ? $publicConfigured : $this->deriveBrokerPublicUrl()
|
||||
);
|
||||
@@ -4133,7 +4129,7 @@ BASH;
|
||||
|
||||
$health = $this->brokerHttpProbe($baseUrl['url'] . '/api/health');
|
||||
if (($health['status_code'] ?? null) === 200 && !empty($health['json']['ok'])) {
|
||||
return array_merge($health, [
|
||||
return array_merge($this->redactBrokerDiagnosticProbe($health), [
|
||||
'ok' => true,
|
||||
'status' => 'connected',
|
||||
'url' => $baseUrl['url'],
|
||||
@@ -4142,7 +4138,7 @@ BASH;
|
||||
}
|
||||
|
||||
if (($health['status_code'] ?? null) === 404 && $this->isBrokerNotFoundProbe($health)) {
|
||||
return array_merge($health, [
|
||||
return array_merge($this->redactBrokerDiagnosticProbe($health), [
|
||||
'ok' => true,
|
||||
'status' => 'connected_legacy',
|
||||
'url' => $baseUrl['url'],
|
||||
@@ -4151,7 +4147,7 @@ BASH;
|
||||
}
|
||||
|
||||
if (($health['status_code'] ?? null) !== null) {
|
||||
return array_merge($health, [
|
||||
return array_merge($this->redactBrokerDiagnosticProbe($health), [
|
||||
'ok' => false,
|
||||
'status' => 'unexpected_response',
|
||||
'url' => $baseUrl['url'],
|
||||
@@ -4159,7 +4155,7 @@ BASH;
|
||||
]);
|
||||
}
|
||||
|
||||
return array_merge($health, [
|
||||
return array_merge($this->redactBrokerDiagnosticProbe($health), [
|
||||
'ok' => false,
|
||||
'status' => 'unreachable',
|
||||
'url' => $baseUrl['url'],
|
||||
@@ -4187,7 +4183,7 @@ BASH;
|
||||
|
||||
if (($diagnostic['status_code'] ?? null) === 200 && !empty($diagnostic['json']['ok'])) {
|
||||
$required = (bool)($diagnostic['json']['shared_secret_required'] ?? false);
|
||||
return array_merge($diagnostic, [
|
||||
return array_merge($this->redactBrokerDiagnosticProbe($diagnostic), [
|
||||
'ok' => true,
|
||||
'status' => $required ? 'validated' : 'not_required',
|
||||
'url' => $baseUrl['url'],
|
||||
@@ -4198,7 +4194,7 @@ BASH;
|
||||
}
|
||||
|
||||
if (($diagnostic['status_code'] ?? null) === 403) {
|
||||
return array_merge($diagnostic, [
|
||||
return array_merge($this->redactBrokerDiagnosticProbe($diagnostic), [
|
||||
'ok' => false,
|
||||
'status' => 'secret_rejected',
|
||||
'url' => $baseUrl['url'],
|
||||
@@ -4211,7 +4207,7 @@ BASH;
|
||||
}
|
||||
|
||||
if (($diagnostic['status_code'] ?? null) !== null) {
|
||||
return array_merge($diagnostic, [
|
||||
return array_merge($this->redactBrokerDiagnosticProbe($diagnostic), [
|
||||
'ok' => false,
|
||||
'status' => 'unexpected_response',
|
||||
'url' => $baseUrl['url'],
|
||||
@@ -4219,7 +4215,7 @@ BASH;
|
||||
]);
|
||||
}
|
||||
|
||||
return array_merge($diagnostic, [
|
||||
return array_merge($this->redactBrokerDiagnosticProbe($diagnostic), [
|
||||
'ok' => false,
|
||||
'status' => 'unreachable',
|
||||
'url' => $baseUrl['url'],
|
||||
@@ -4242,7 +4238,7 @@ BASH;
|
||||
);
|
||||
|
||||
if (($legacy['status_code'] ?? null) === 200 && !empty($legacy['json']['ok'])) {
|
||||
return array_merge($legacy, [
|
||||
return array_merge($this->redactBrokerDiagnosticProbe($legacy), [
|
||||
'ok' => true,
|
||||
'status' => 'validated_legacy',
|
||||
'url' => $baseUrl['url'],
|
||||
@@ -4251,7 +4247,7 @@ BASH;
|
||||
}
|
||||
|
||||
if (($legacy['status_code'] ?? null) === 403) {
|
||||
return array_merge($legacy, [
|
||||
return array_merge($this->redactBrokerDiagnosticProbe($legacy), [
|
||||
'ok' => false,
|
||||
'status' => 'secret_rejected',
|
||||
'url' => $baseUrl['url'],
|
||||
@@ -4259,7 +4255,7 @@ BASH;
|
||||
]);
|
||||
}
|
||||
|
||||
return array_merge($legacy, [
|
||||
return array_merge($this->redactBrokerDiagnosticProbe($legacy), [
|
||||
'ok' => false,
|
||||
'status' => ($legacy['status_code'] ?? null) === null ? 'unreachable' : 'unexpected_response',
|
||||
'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
|
||||
* @return array<string,mixed>
|
||||
@@ -4345,7 +4352,7 @@ BASH;
|
||||
'elapsed_ms' => $elapsedMs,
|
||||
'error' => 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_public_broker_url_c::class,
|
||||
edgegateway_broker_auth_mode_c::class,
|
||||
edgegateway_broker_shared_secret_c::class,
|
||||
]);
|
||||
$this->enabled = new edgegateway_enabled_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');
|
||||
$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
|
||||
@@ -71,7 +79,12 @@ class edgeGatewayConfigRoute
|
||||
}
|
||||
|
||||
$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');
|
||||
$response->success((new edge_gateway_manager())->diagnoseBrokerConfiguration($payload));
|
||||
$response->success((new edge_gateway_manager())->diagnoseBrokerConfiguration($diagnosticOptions));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
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
|
||||
{
|
||||
@@ -52,6 +53,7 @@ class email_template_new_customer
|
||||
*/
|
||||
public function generate_html(): string
|
||||
{
|
||||
$customer_label = htmlspecialchars($this->getCustomerRegistrationLabel(), ENT_QUOTES, 'UTF-8');
|
||||
ob_start();
|
||||
# Start of the html
|
||||
?>
|
||||
@@ -73,7 +75,7 @@ class email_template_new_customer
|
||||
|
||||
<!-- 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;">
|
||||
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>
|
||||
|
||||
<!-- You can now wash your trucks -->
|
||||
@@ -185,4 +187,19 @@ class email_template_new_customer
|
||||
# End of the html
|
||||
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);
|
||||
// Set options to return the response and handle SSL
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
|
||||
// Execute the request
|
||||
$response = curl_exec($ch);
|
||||
// Check for errors
|
||||
@@ -44,11 +44,7 @@ class limble_request implements limble_request_i
|
||||
// Check if the response is successful
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
$slack = new \classes\slack();
|
||||
echo 'Attempting credentials: ' . $url . ' with method: ' . $method . ' and data: ' . json_encode($data) . "\n";
|
||||
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');
|
||||
$slack->send_message('Limble Request Failed with status code: ' . $httpCode, 'Limble Request Error');
|
||||
throw new \Exception('Request failed with status code ' . $httpCode);
|
||||
}
|
||||
// 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
|
||||
return 'Authorization: Basic ' . base64_encode($client_id . ':' . $client_secret);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ class selfserve_lane_command_arguments
|
||||
{
|
||||
public ?string $license_plate = null;
|
||||
public ?int $customer_number = null;
|
||||
public ?int $subuser_id = null;
|
||||
public ?string $wash_mode = null;
|
||||
public bool $defer_relay_side_effects = false;
|
||||
|
||||
/**
|
||||
@@ -25,6 +27,28 @@ class selfserve_lane_command_arguments
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setSubuserId(?int $subuser_id): self
|
||||
{
|
||||
$this->subuser_id = $subuser_id !== null && $subuser_id > 0 ? $subuser_id : null;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setWashMode(?string $wash_mode): self
|
||||
{
|
||||
$normalized = strtolower(trim((string)$wash_mode));
|
||||
if ($wash_mode === null || $normalized === '') {
|
||||
$this->wash_mode = null;
|
||||
return $this;
|
||||
}
|
||||
|
||||
if (!in_array($normalized, ['manual', 'machine'], true)) {
|
||||
throw new \InvalidArgumentException('Invalid wash type: ' . $wash_mode);
|
||||
}
|
||||
|
||||
$this->wash_mode = $normalized;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setDeferRelaySideEffects(bool $defer_relay_side_effects): self
|
||||
{
|
||||
$this->defer_relay_side_effects = $defer_relay_side_effects;
|
||||
@@ -40,6 +64,15 @@ class selfserve_lane_command_arguments
|
||||
if (array_key_exists('customer_number', $params)) {
|
||||
$this->setCustomerNumber($params['customer_number']);
|
||||
}
|
||||
if (array_key_exists('subuser_id', $params)) {
|
||||
$this->setSubuserId($params['subuser_id'] === null ? null : (int)$params['subuser_id']);
|
||||
}
|
||||
if (array_key_exists('wash_type', $params)) {
|
||||
$this->setWashMode($params['wash_type'] === null ? null : (string)$params['wash_type']);
|
||||
}
|
||||
if (array_key_exists('wash_mode', $params)) {
|
||||
$this->setWashMode($params['wash_mode'] === null ? null : (string)$params['wash_mode']);
|
||||
}
|
||||
if (array_key_exists('defer_relay_side_effects', $params)) {
|
||||
$this->setDeferRelaySideEffects(filter_var(
|
||||
$params['defer_relay_side_effects'],
|
||||
|
||||
@@ -83,7 +83,7 @@ class selfserve_studio_action_runner
|
||||
continue;
|
||||
}
|
||||
$conditionId = $action['condition_id'];
|
||||
if ($conditionId !== null && $conditionResults !== null && (($conditionResults[$conditionId] ?? false) !== true)) {
|
||||
if ($conditionId !== null && (($conditionResults[$conditionId] ?? false) !== true)) {
|
||||
continue;
|
||||
}
|
||||
$actions[] = $action;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -103,49 +103,79 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
public function synchronizeSession(int $laneId, string $reg, ?int $customerNumber = null, bool $activateMachine = true, ?int $vehicleTypeIdOverride = null, bool $syncRelayState = true, array $options = []): array
|
||||
{
|
||||
$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) {
|
||||
return $session->exists()
|
||||
? $this->getSessionSummary((int)$session->id)
|
||||
: $this->formatBlockedSessionSummary($snapshot);
|
||||
if (($snapshot['evaluation_trace']['disabled_lane'] ?? false) === true) {
|
||||
return [
|
||||
'session' => $session,
|
||||
'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) {
|
||||
$this->syncMachineRelayFromVisibleServices($snapshot, $session, $activateMachine);
|
||||
if ($session->exists()) {
|
||||
$this->syncMachineRelayFromVisibleServices($snapshot, $session, $activateMachine);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->getSessionSummary((int)$session->id);
|
||||
@@ -273,28 +303,19 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
return;
|
||||
}
|
||||
|
||||
$this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE);
|
||||
$this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE_CLEANER);
|
||||
$this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE);
|
||||
$this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE_CLEANER);
|
||||
} catch (\Throwable) {
|
||||
// Best effort only; session completion flow must continue.
|
||||
}
|
||||
}
|
||||
|
||||
protected function turnOffRelayIfConfiguredAndOn(selfserve_lane $lane, selfserve_lane_relay $relay): void
|
||||
protected function turnOffRelayIfConfigured(selfserve_lane $lane, selfserve_lane_relay $relay): void
|
||||
{
|
||||
if (!$this->isRelayConfiguredForLane($lane, $relay)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$status = $lane->getRelayStatus($relay);
|
||||
if ((bool)($status['on'] ?? false) !== true) {
|
||||
return;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// If relay status can't be read, still attempt turn-off as best effort.
|
||||
}
|
||||
|
||||
try {
|
||||
$lane->setRelayStatusHard($relay, false);
|
||||
} catch (\Throwable) {
|
||||
@@ -404,7 +425,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
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
|
||||
? $this->findLatestOpenSession($laneId, selfserve::standardize_registration($reg), $customerNumber)
|
||||
@@ -414,8 +435,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
return null;
|
||||
}
|
||||
|
||||
$session->markCompleted($orderId);
|
||||
$this->disableMachineRelayForCompletedWash($laneId);
|
||||
if (!$session->markCompletedIfOpen($orderId)) {
|
||||
return $this->getSessionSummary((int)$session->id);
|
||||
}
|
||||
if ($disableRelays) {
|
||||
$this->disableMachineRelayForCompletedWash($laneId);
|
||||
}
|
||||
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_COMPLETED, [
|
||||
'lane_id' => $laneId,
|
||||
'reg' => $reg === null ? (string)$session->reg->value() : selfserve::standardize_registration($reg),
|
||||
@@ -459,9 +484,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'runtime_before_reset' => $runtimeSnapshot,
|
||||
'forced_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
$session->markForceStopped($orderId, $eventPayload);
|
||||
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::SESSION_FORCE_STOPPED, $eventPayload);
|
||||
$summary = $this->getSessionSummary((int)$session->id);
|
||||
if (!$session->markForceStoppedIfOpen($orderId, $eventPayload)) {
|
||||
$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());
|
||||
@@ -723,14 +751,14 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
|
||||
protected function enableMachineRelayIfAllowed(array $snapshot, selfserve_wash_sessions_o $session): void
|
||||
{
|
||||
if ((bool)$session->machine_relay_enabled->value() === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
$laneId = (int)$snapshot['lane']['id'];
|
||||
$lane = (new selfserve())->lane($laneId);
|
||||
$this->enableCleanerRelayForStartedWash($lane);
|
||||
|
||||
if ((bool)$session->machine_relay_enabled->value() === true) {
|
||||
return;
|
||||
}
|
||||
|
||||
$session->markRelayEnabled();
|
||||
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::MACHINE_RELAY_ENABLED, [
|
||||
'lane_id' => $laneId,
|
||||
@@ -841,6 +869,16 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$tasks = $this->buildDebugTasks($snapshot, (array)($candidates['tasks'] ?? []), $lookups, (array)($options['gateway_workspace'] ?? []));
|
||||
$actions = $this->buildDebugActions($snapshot, (array)($candidates['actions'] ?? []), $lookups);
|
||||
$hardware = $this->buildDebugHardware($snapshot, $tasks, (array)($options['gateway_workspace'] ?? []), $lookups, $actions);
|
||||
$dynamicImageButtons = $this->buildDebugDynamicImageButtons($tasks);
|
||||
$decisions = $this->buildDebugDecisions(
|
||||
$questions,
|
||||
$conditions,
|
||||
$rules,
|
||||
$tasks,
|
||||
$actions,
|
||||
(array)($hardware['signal_timeline'] ?? []),
|
||||
$dynamicImageButtons
|
||||
);
|
||||
$recommendations = $this->buildDebugRecommendations($snapshot, $questions, $tasks, $hardware, $conditions, $rules);
|
||||
$summary = $this->buildDebugSummary($snapshot, $recommendations, $hardware);
|
||||
$stages = $this->buildDebugStages($snapshot, $questions, $conditions, $rules, $tasks, $hardware, $summary, $lookups);
|
||||
@@ -871,6 +909,8 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'rules' => $rules,
|
||||
'tasks' => $tasks,
|
||||
'actions' => $actions,
|
||||
'dynamic_image_buttons' => $dynamicImageButtons,
|
||||
'decisions' => $decisions,
|
||||
'hardware' => $hardware,
|
||||
'signal_timeline' => (array)($hardware['signal_timeline'] ?? []),
|
||||
'graph_annotations' => $annotations,
|
||||
@@ -981,6 +1021,9 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$conditionResults = is_array($snapshot['evaluation_trace']['visibility_condition_results'] ?? null)
|
||||
? (array)$snapshot['evaluation_trace']['visibility_condition_results']
|
||||
: [];
|
||||
$expressionTraces = is_array($snapshot['evaluation_trace']['visibility_expression_traces'] ?? null)
|
||||
? (array)$snapshot['evaluation_trace']['visibility_expression_traces']
|
||||
: [];
|
||||
$items = [];
|
||||
|
||||
foreach ($questions as $question) {
|
||||
@@ -992,20 +1035,43 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$visible = isset($visibleIds[$questionId]);
|
||||
$answer = array_key_exists($questionId, $answers) ? $answers[$questionId] : null;
|
||||
$state = !$visible ? 'hidden' : ($answer === null ? 'missing' : 'answered');
|
||||
$label = (string)($question['question'] ?? $this->debugLabel($lookups, 'questions', $questionId, 'Question ' . $questionId));
|
||||
$conditionLabel = $gateId === null ? 'Always visible' : $this->debugLabel($lookups, 'conditions', $gateId, 'Condition ' . $gateId);
|
||||
$gateSatisfied = $gateId === null || (($conditionResults[$gateId] ?? false) === true);
|
||||
$causes = [];
|
||||
if ($gateId !== null) {
|
||||
$causes[] = $this->debugCause(
|
||||
'condition',
|
||||
$gateId,
|
||||
$conditionLabel,
|
||||
true,
|
||||
($conditionResults[$gateId] ?? null),
|
||||
$this->debugExpressionTraceReason($expressionTraces[$gateId] ?? null)
|
||||
);
|
||||
}
|
||||
if ($visible && $answer === null) {
|
||||
$causes[] = $this->debugCause('question', $questionId, $label, 'answered', 'missing', 'Visible question has no simulated answer.');
|
||||
}
|
||||
$reason = $visible
|
||||
? ($answer === null
|
||||
? 'Question ' . $label . ' is visible but has no answer.'
|
||||
: 'Question ' . $label . ' is visible and answered ' . $this->debugValueLabel($answer) . '.')
|
||||
: 'Question ' . $label . ' hidden because visibility condition ' . $conditionLabel . ' expected true, actual ' . $this->debugValueLabel($conditionResults[$gateId] ?? null) . '.';
|
||||
$items[] = [
|
||||
'kind' => 'question',
|
||||
'id' => $questionId,
|
||||
'node_id' => 'question:' . $questionId,
|
||||
'label' => (string)($question['question'] ?? $this->debugLabel($lookups, 'questions', $questionId, 'Question ' . $questionId)),
|
||||
'node_ids' => ['question:' . $questionId],
|
||||
'label' => $label,
|
||||
'visible' => $visible,
|
||||
'state' => $state,
|
||||
'answer' => $answer,
|
||||
'answer_source' => $sources[$questionId] ?? 'missing',
|
||||
'condition_id' => $gateId,
|
||||
'condition' => $gateId === null ? 'Always visible' : $this->debugLabel($lookups, 'conditions', $gateId, 'Condition ' . $gateId),
|
||||
'gate_satisfied' => $gateId === null || (($conditionResults[$gateId] ?? false) === true),
|
||||
'reason' => $visible
|
||||
? ($answer === null ? 'Visible question is missing an answer.' : 'Visible question has an answer.')
|
||||
: 'Visibility condition did not pass.',
|
||||
'condition' => $conditionLabel,
|
||||
'gate_satisfied' => $gateSatisfied,
|
||||
'reason' => $reason,
|
||||
'causes' => $causes,
|
||||
'order_priority' => (int)($question['order_priority'] ?? 0),
|
||||
];
|
||||
}
|
||||
@@ -1036,19 +1102,33 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$actual = $objectType === 'condition' ? ($conditionResults[$objectId] ?? null) : ($answers[$objectId] ?? null);
|
||||
$satisfied = $this->debugRuleSatisfied((string)($rule['type'] ?? ''), $actual);
|
||||
$lookupType = $objectType === 'condition' ? 'conditions' : 'questions';
|
||||
$label = (string)($rule['name'] ?? $this->debugLabel($lookups, 'rules', $ruleId, 'Rule ' . $ruleId));
|
||||
$objectLabel = $this->debugLabel($lookups, $lookupType, $objectId, ucfirst($objectType) . ' ' . $objectId);
|
||||
$expected = $this->debugRuleExpectedValue((string)($rule['type'] ?? ''));
|
||||
$invalidReference = $objectId <= 0 || ($objectType !== 'question' && $objectType !== 'condition');
|
||||
$reason = $invalidReference
|
||||
? 'Rule ' . $label . ' skipped because its referenced object is invalid.'
|
||||
: ($satisfied
|
||||
? 'Rule ' . $label . ' passed because ' . $objectLabel . ' matched ' . $this->debugExpectedLabel($expected) . '.'
|
||||
: 'Rule ' . $label . ' failed because ' . $objectLabel . ' expected ' . $this->debugExpectedLabel($expected) . ', actual ' . $this->debugValueLabel($actual) . '.');
|
||||
$items[] = [
|
||||
'kind' => 'rule',
|
||||
'id' => $ruleId,
|
||||
'node_id' => 'rule:' . $ruleId,
|
||||
'node_ids' => ['rule:' . $ruleId],
|
||||
'condition_id' => (int)($rule['condition_id'] ?? 0),
|
||||
'label' => (string)($rule['name'] ?? $this->debugLabel($lookups, 'rules', $ruleId, 'Rule ' . $ruleId)),
|
||||
'label' => $label,
|
||||
'type' => (string)($rule['type'] ?? ''),
|
||||
'object_type' => $objectType,
|
||||
'object_id' => $objectId,
|
||||
'object_label' => $this->debugLabel($lookups, $lookupType, $objectId, ucfirst($objectType) . ' ' . $objectId),
|
||||
'object_label' => $objectLabel,
|
||||
'actual_value' => $actual,
|
||||
'satisfied' => $satisfied,
|
||||
'invalid_reference' => $objectId <= 0 || ($objectType !== 'question' && $objectType !== 'condition'),
|
||||
'reason' => $satisfied ? 'Rule passed for the simulated value.' : 'Rule did not pass for the simulated value.',
|
||||
'invalid_reference' => $invalidReference,
|
||||
'reason' => $reason,
|
||||
'causes' => [
|
||||
$this->debugCause($objectType ?: 'object', $objectId, $objectLabel, $expected, $actual, $invalidReference ? 'Invalid rule reference.' : null),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1082,10 +1162,29 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$expression = is_array($condition['expression'] ?? null) ? (array)$condition['expression'] : [];
|
||||
$expressionTrace = is_array($expressionTraces[$conditionId] ?? null) ? (array)$expressionTraces[$conditionId] : null;
|
||||
$nextFix = $expressionTrace === null ? null : $this->nextFixForExpressionTrace($expressionTrace, $lookups);
|
||||
$label = (string)($condition['name'] ?? $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId));
|
||||
$causes = [];
|
||||
$failedExpressionCause = $expressionTrace === null ? null : $this->debugFailedExpressionCause($expressionTrace, $lookups);
|
||||
if ($failedExpressionCause !== null) {
|
||||
$causes[] = $failedExpressionCause;
|
||||
} elseif (!$result) {
|
||||
foreach ((array)($rulesByCondition[$conditionId] ?? []) as $rule) {
|
||||
if (($rule['satisfied'] ?? false) !== true) {
|
||||
foreach ((array)($rule['causes'] ?? []) as $cause) {
|
||||
if (is_array($cause)) {
|
||||
$causes[] = $cause;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$items[] = [
|
||||
'kind' => 'condition',
|
||||
'id' => $conditionId,
|
||||
'node_id' => 'condition:' . $conditionId,
|
||||
'label' => (string)($condition['name'] ?? $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId)),
|
||||
'node_ids' => ['condition:' . $conditionId],
|
||||
'label' => $label,
|
||||
'result' => $result,
|
||||
'state' => $result ? 'passed' : 'failed',
|
||||
'parent_condition_id' => $this->nullableInt($condition['condition_id'] ?? null),
|
||||
@@ -1098,6 +1197,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'reason' => in_array($conditionId, $cycleIds, true)
|
||||
? 'Condition dependency cycle detected.'
|
||||
: ($expressionTrace['expression']['reason'] ?? ($result ? 'Condition passed.' : 'Condition failed or has no passing rules.')),
|
||||
'causes' => $causes,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1130,6 +1230,12 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$gateTrace[(int)($trace['task_id'] ?? 0)] = $trace;
|
||||
}
|
||||
}
|
||||
$conditionResults = is_array($snapshot['evaluation_trace']['condition_results'] ?? null)
|
||||
? (array)$snapshot['evaluation_trace']['condition_results']
|
||||
: [];
|
||||
$visibleAnswers = is_array($snapshot['debug_candidates']['visible_answers'] ?? null)
|
||||
? (array)$snapshot['debug_candidates']['visible_answers']
|
||||
: (is_array($snapshot['answers'] ?? null) ? (array)$snapshot['answers'] : []);
|
||||
$bindingsByService = $this->debugGatewayBindingsByService($gatewayWorkspace);
|
||||
$items = [];
|
||||
|
||||
@@ -1149,25 +1255,32 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$active = isset($activeIds[$taskId]);
|
||||
$gateType = (string)($trace['gate_type'] ?? $task['gate_type'] ?? selfserve_task_gate_type::ALWAYS->value);
|
||||
$gateRefId = $this->nullableInt($trace['gate_ref_id'] ?? $task['gate_ref_id'] ?? $task['condition_id'] ?? null);
|
||||
$gateRefLabel = $gateRefId === null ? 'Always' : $this->debugGateReferenceLabel($lookups, $gateType, $gateRefId);
|
||||
$label = (string)($task['task'] ?? $this->debugLabel($lookups, 'tasks', $taskId, 'Task ' . $taskId));
|
||||
$gateSatisfied = ($trace['satisfied'] ?? false) === true;
|
||||
$gateDecision = $this->debugTaskGateDecision($label, $gateType, $gateRefId, $gateRefLabel, $active, $gateSatisfied, $conditionResults, $visibleAnswers);
|
||||
$taskAttachments = is_array($task['attachments'] ?? null) ? array_values($task['attachments']) : ($activeAttachments[$taskId] ?? []);
|
||||
$items[] = [
|
||||
'kind' => 'task',
|
||||
'id' => $taskId,
|
||||
'node_id' => 'task:' . $taskId,
|
||||
'label' => (string)($task['task'] ?? $this->debugLabel($lookups, 'tasks', $taskId, 'Task ' . $taskId)),
|
||||
'node_ids' => ['task:' . $taskId],
|
||||
'label' => $label,
|
||||
'description' => (string)($task['description'] ?? ''),
|
||||
'active' => $active,
|
||||
'state' => $active ? 'active' : 'blocked',
|
||||
'gate_type' => $gateType,
|
||||
'gate_ref_id' => $gateRefId,
|
||||
'gate_ref_label' => $gateRefId === null ? 'Always' : $this->debugGateReferenceLabel($lookups, $gateType, $gateRefId),
|
||||
'gate_satisfied' => ($trace['satisfied'] ?? false) === true,
|
||||
'gate_ref_label' => $gateRefLabel,
|
||||
'gate_satisfied' => $gateSatisfied,
|
||||
'services' => $services,
|
||||
'buttons' => $this->normalizeButtonList($task['buttons'] ?? null),
|
||||
'dynamic_images_vehicle_type' => ($task['dynamic_images_vehicle_type'] ?? null) === null ? null : (int)$task['dynamic_images_vehicle_type'],
|
||||
'attachments' => $taskAttachments,
|
||||
'relay_bindings' => $bindings,
|
||||
'order_priority' => (int)($task['order_priority'] ?? 0),
|
||||
'reason' => $active ? 'Task gate passed.' : 'Task gate did not pass.',
|
||||
'reason' => $gateDecision['reason'],
|
||||
'causes' => $gateDecision['causes'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1212,42 +1325,59 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
$modeMatches = in_array((string)$action['wash_mode'], [selfserve_studio_actions::MODE_BOTH, $expectedMode], true);
|
||||
$scopeMatches = true;
|
||||
$scopeReason = null;
|
||||
$scopeCause = null;
|
||||
if ((int)$action['department'] !== 0 && (int)$action['department'] !== $departmentId) {
|
||||
$scopeMatches = false;
|
||||
$scopeReason = 'Action department scope does not match the simulated lane.';
|
||||
$scopeCause = $this->debugCause('department', (int)$action['department'], 'Action department scope', $departmentId, (int)$action['department'], $scopeReason);
|
||||
} elseif ((int)$action['lane'] !== 0 && (int)$action['lane'] !== $laneId) {
|
||||
$scopeMatches = false;
|
||||
$scopeReason = 'Action lane scope does not match the simulated lane.';
|
||||
$scopeCause = $this->debugCause('lane', (int)$action['lane'], 'Action lane scope', $laneId, (int)$action['lane'], $scopeReason);
|
||||
} elseif ((int)$action['product'] !== 0 && ($vehicleTypeId === null || (int)$action['product'] !== $vehicleTypeId)) {
|
||||
$scopeMatches = false;
|
||||
$scopeReason = 'Action vehicle type scope does not match the simulated vehicle.';
|
||||
$scopeCause = $this->debugCause('vehicle_type', (int)$action['product'], 'Action vehicle type scope', $vehicleTypeId, (int)$action['product'], $scopeReason);
|
||||
} elseif ($action['machine_type_id'] !== null && (int)$action['machine_type_id'] !== ($machineTypeId ?? 0)) {
|
||||
$scopeMatches = false;
|
||||
$scopeReason = 'Action machine type scope does not match the simulated lane.';
|
||||
$scopeCause = $this->debugCause('machine_type', (int)$action['machine_type_id'], 'Action machine type scope', $machineTypeId, (int)$action['machine_type_id'], $scopeReason);
|
||||
}
|
||||
|
||||
$conditionId = $this->nullableInt($action['condition_id'] ?? null);
|
||||
$conditionSatisfied = $conditionId === null || (($conditionResults[$conditionId] ?? false) === true);
|
||||
$enabled = (bool)($action['enabled'] ?? true);
|
||||
$active = $enabled && $modeMatches && $scopeMatches && $conditionSatisfied;
|
||||
$label = (string)$action['name'];
|
||||
$conditionLabel = $conditionId === null ? 'Always' : $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId);
|
||||
$causes = [];
|
||||
$reason = 'Action would run for this simulator event.';
|
||||
if (!$enabled) {
|
||||
$reason = 'Action is disabled.';
|
||||
$causes[] = $this->debugCause('action', $actionId, $label, true, false, $reason);
|
||||
} elseif (!$modeMatches) {
|
||||
$reason = 'Action wash mode ' . (string)$action['wash_mode'] . ' does not match simulated ' . $expectedMode . ' mode.';
|
||||
$causes[] = $this->debugCause('wash_mode', $actionId, 'Action wash mode', selfserve_studio_actions::MODE_BOTH . ' or ' . $expectedMode, (string)$action['wash_mode'], $reason);
|
||||
} elseif (!$scopeMatches) {
|
||||
$reason = $scopeReason ?? 'Action scope does not match the simulated lane.';
|
||||
if ($scopeCause !== null) {
|
||||
$causes[] = $scopeCause;
|
||||
}
|
||||
} elseif (!$conditionSatisfied) {
|
||||
$reason = 'Action condition gate did not pass.';
|
||||
$reason = 'Action ' . $label . ' skipped because condition ' . $conditionLabel . ' expected true, actual ' . $this->debugValueLabel($conditionResults[$conditionId] ?? null) . '.';
|
||||
$causes[] = $this->debugCause('condition', $conditionId, $conditionLabel, true, ($conditionResults[$conditionId] ?? null), $reason);
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'kind' => 'action',
|
||||
'id' => $actionId,
|
||||
'node_id' => 'action:' . $actionId,
|
||||
'label' => (string)$action['name'],
|
||||
'node_ids' => ['action:' . $actionId],
|
||||
'label' => $label,
|
||||
'active' => $active,
|
||||
'state' => $active ? 'active' : 'skipped',
|
||||
'reason' => $reason,
|
||||
'causes' => $causes,
|
||||
'event' => (string)$action['event'],
|
||||
'event_label' => selfserve_studio_actions::eventLabel((string)$action['event']),
|
||||
'wash_mode' => (string)$action['wash_mode'],
|
||||
@@ -1257,7 +1387,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'relay_role' => selfserve_studio_actions::relayRoleForOperation((string)$action['operation']),
|
||||
'relay_state' => $action['relay_state'],
|
||||
'condition_id' => $conditionId,
|
||||
'condition' => $conditionId === null ? 'Always' : $this->debugLabel($lookups, 'conditions', $conditionId, 'Condition ' . $conditionId),
|
||||
'condition' => $conditionLabel,
|
||||
'condition_satisfied' => $conditionSatisfied,
|
||||
'scope' => [
|
||||
'department' => (int)$action['department'],
|
||||
@@ -1758,7 +1888,30 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
string $predictedStatus,
|
||||
?string $reason
|
||||
): array {
|
||||
$id = 'signal:' . $sequence;
|
||||
$label = trim($runtimeStage . ' ' . $relayRole . ' ' . $signalType);
|
||||
$targetBinding = $binding['node_id'] ?? null;
|
||||
$nodeIds = [$id];
|
||||
if (isset($payload['action_id'])) {
|
||||
$nodeIds[] = 'action:' . (int)$payload['action_id'];
|
||||
}
|
||||
if (is_string($targetBinding) && $targetBinding !== '') {
|
||||
$nodeIds[] = $targetBinding;
|
||||
}
|
||||
if ($relayId !== null && trim($relayId) !== '') {
|
||||
$nodeIds[] = 'relay:' . $relayId;
|
||||
}
|
||||
$causes = $reason === null ? [] : [
|
||||
$this->debugCause('signal', $id, $label, 'sent', $predictedStatus, $reason),
|
||||
];
|
||||
|
||||
return [
|
||||
'kind' => 'signal',
|
||||
'id' => $id,
|
||||
'node_id' => $id,
|
||||
'node_ids' => array_values(array_unique($nodeIds)),
|
||||
'label' => $label,
|
||||
'state' => $predictedStatus,
|
||||
'sequence' => $sequence,
|
||||
'runtime_stage' => $runtimeStage,
|
||||
'signal_type' => $signalType,
|
||||
@@ -1779,6 +1932,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
'predicted_status' => $predictedStatus,
|
||||
'skip_block_reason' => $reason,
|
||||
'reason' => $reason,
|
||||
'causes' => $causes,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2085,6 +2239,333 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
};
|
||||
}
|
||||
|
||||
protected function debugRuleExpectedValue(string $type): mixed
|
||||
{
|
||||
return match (strtoupper(trim($type))) {
|
||||
'IS_TRUE', 'IS_TRUE_OR_ANY_TRUE' => true,
|
||||
'IS_FALSE' => false,
|
||||
'IS_SET' => 'set',
|
||||
'IS_TRUE_OR_NOT_SET' => 'true or missing',
|
||||
'IS_FALSE_OR_NOT_SET' => 'false or missing',
|
||||
default => strtolower(str_replace('_', ' ', trim($type))),
|
||||
};
|
||||
}
|
||||
|
||||
protected function debugExpectedLabel(mixed $expected): string
|
||||
{
|
||||
return $this->debugValueLabel($expected);
|
||||
}
|
||||
|
||||
protected function debugValueLabel(mixed $value): string
|
||||
{
|
||||
if ($value === true) {
|
||||
return 'true';
|
||||
}
|
||||
if ($value === false) {
|
||||
return 'false';
|
||||
}
|
||||
if ($value === null) {
|
||||
return 'missing';
|
||||
}
|
||||
if (is_array($value)) {
|
||||
$encoded = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
return $encoded === false ? 'array' : $encoded;
|
||||
}
|
||||
if ($value instanceof \Stringable) {
|
||||
return (string)$value;
|
||||
}
|
||||
return trim((string)$value) === '' ? 'empty' : (string)$value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
protected function debugCause(string $kind, mixed $id, string $label, mixed $expected, mixed $actual, ?string $reason = null): array
|
||||
{
|
||||
return [
|
||||
'kind' => $kind,
|
||||
'id' => $id,
|
||||
'label' => $label,
|
||||
'expected' => $expected,
|
||||
'actual' => $actual,
|
||||
'expected_label' => $this->debugExpectedLabel($expected),
|
||||
'actual_label' => $this->debugValueLabel($actual),
|
||||
'reason' => $reason,
|
||||
];
|
||||
}
|
||||
|
||||
protected function debugExpressionTraceReason(mixed $trace): ?string
|
||||
{
|
||||
if (!is_array($trace)) {
|
||||
return null;
|
||||
}
|
||||
$expression = is_array($trace['expression'] ?? null) ? (array)$trace['expression'] : (array)$trace;
|
||||
$failed = $this->firstFailedPredicateTrace($expression);
|
||||
if ($failed !== null) {
|
||||
return (string)($failed['reason'] ?? 'Predicate did not pass.');
|
||||
}
|
||||
return isset($trace['reason']) ? (string)$trace['reason'] : (isset($expression['reason']) ? (string)$expression['reason'] : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $trace
|
||||
* @param array<string,mixed> $lookups
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
protected function debugFailedExpressionCause(array $trace, array $lookups): ?array
|
||||
{
|
||||
$expression = is_array($trace['expression'] ?? null) ? (array)$trace['expression'] : $trace;
|
||||
$failed = $this->firstFailedPredicateTrace($expression);
|
||||
if ($failed === null) {
|
||||
if (($expression['result'] ?? true) === false) {
|
||||
return $this->debugCause(
|
||||
'expression',
|
||||
$trace['condition_id'] ?? null,
|
||||
'Condition expression',
|
||||
true,
|
||||
false,
|
||||
(string)($expression['reason'] ?? $trace['reason'] ?? 'Expression did not pass.')
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
$subjectType = strtolower((string)($failed['subject_type'] ?? 'question'));
|
||||
$subjectId = (int)($failed['subject_id'] ?? 0);
|
||||
$lookupType = $subjectType === 'condition' ? 'conditions' : 'questions';
|
||||
$label = $this->debugLabel($lookups, $lookupType, $subjectId, ucfirst($subjectType) . ' ' . $subjectId);
|
||||
$expected = $this->debugRuleExpectedValue((string)($failed['operator'] ?? 'IS_TRUE'));
|
||||
$actual = $failed['actual_value'] ?? null;
|
||||
$reason = ucfirst($subjectType) . ' ' . $label . ' expected ' . $this->debugExpectedLabel($expected) . ', actual ' . $this->debugValueLabel($actual) . '.';
|
||||
|
||||
return $this->debugCause($subjectType ?: 'predicate', $subjectId, $label, $expected, $actual, $reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,bool|null> $conditionResults
|
||||
* @param array<int,bool|null> $visibleAnswers
|
||||
* @return array{reason:string,causes:array<int,array<string,mixed>>}
|
||||
*/
|
||||
protected function debugTaskGateDecision(string $taskLabel, string $gateType, ?int $gateRefId, string $gateRefLabel, bool $active, bool $gateSatisfied, array $conditionResults, array $visibleAnswers): array
|
||||
{
|
||||
$gateType = strtoupper(trim($gateType));
|
||||
if ($gateType === '' || $gateType === selfserve_task_gate_type::ALWAYS->value) {
|
||||
if (!$active && $gateSatisfied) {
|
||||
return [
|
||||
'reason' => 'Task ' . $taskLabel . ' skipped because it was removed after service filtering even though gate ALWAYS passed.',
|
||||
'causes' => [
|
||||
$this->debugCause('task', null, $taskLabel, 'included after gates', 'filtered', 'The task gate passed, but the task is not in the simulated end-user task list.'),
|
||||
],
|
||||
];
|
||||
}
|
||||
return [
|
||||
'reason' => $active
|
||||
? 'Task ' . $taskLabel . ' active because gate ALWAYS is open.'
|
||||
: 'Task ' . $taskLabel . ' blocked because gate ALWAYS expected true, actual false.',
|
||||
'causes' => $active ? [] : [
|
||||
$this->debugCause('gate', null, 'ALWAYS', true, false, 'ALWAYS gate unexpectedly did not pass.'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$actual = $gateType === selfserve_task_gate_type::CONDITION->value
|
||||
? ($gateRefId === null ? null : ($conditionResults[$gateRefId] ?? null))
|
||||
: ($gateRefId === null ? null : ($visibleAnswers[$gateRefId] ?? null));
|
||||
$sourceKind = $gateType === selfserve_task_gate_type::CONDITION->value ? 'condition' : 'question';
|
||||
if (!$active && $gateSatisfied) {
|
||||
return [
|
||||
'reason' => 'Task ' . $taskLabel . ' skipped because it was removed after service filtering even though gate ' . $gateType . ' ' . $gateRefLabel . ' passed.',
|
||||
'causes' => [
|
||||
$this->debugCause($sourceKind, $gateRefId, $gateRefLabel, true, $actual, 'Gate passed.'),
|
||||
$this->debugCause('task', null, $taskLabel, 'included after gates', 'filtered', 'The task gate passed, but the task is not in the simulated end-user task list.'),
|
||||
],
|
||||
];
|
||||
}
|
||||
$reason = 'Task ' . $taskLabel . ($active ? ' active' : ' blocked') . ' because gate ' . $gateType . ' ' . $gateRefLabel . ' expected true, actual ' . $this->debugValueLabel($actual) . '.';
|
||||
|
||||
return [
|
||||
'reason' => $reason,
|
||||
'causes' => $active ? [] : [
|
||||
$this->debugCause($sourceKind, $gateRefId, $gateRefLabel, true, $actual, $reason),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $tasks
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
protected function buildDebugDynamicImageButtons(array $tasks): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($tasks as $task) {
|
||||
$taskId = (int)($task['id'] ?? 0);
|
||||
if ($taskId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$taskLabel = (string)($task['label'] ?? $task['task'] ?? ('Task ' . $taskId));
|
||||
$active = (bool)($task['active'] ?? false);
|
||||
foreach ($this->dynamicImageButtonSequenceForTask($task) as $index => $button) {
|
||||
$buttonLabel = $this->debugDynamicImageButtonLabel($button);
|
||||
$id = $taskId . ':' . (int)$index;
|
||||
$nodeId = 'dynamic_image_button:' . $id;
|
||||
$reason = $active
|
||||
? 'Dynamic-image button ' . $buttonLabel . ' is available because task ' . $taskLabel . ' is active.'
|
||||
: 'Dynamic-image button ' . $buttonLabel . ' hidden because task ' . $taskLabel . ' is blocked.';
|
||||
$items[] = [
|
||||
'kind' => 'dynamic_image_button',
|
||||
'id' => $id,
|
||||
'node_id' => $nodeId,
|
||||
'node_ids' => ['task:' . $taskId, $nodeId],
|
||||
'label' => $buttonLabel,
|
||||
'task_id' => $taskId,
|
||||
'task_label' => $taskLabel,
|
||||
'button_index' => (int)$index,
|
||||
'button' => $button,
|
||||
'state' => $active ? 'active' : 'hidden',
|
||||
'reason' => $reason,
|
||||
'causes' => $active ? [] : [
|
||||
$this->debugCause('task', $taskId, $taskLabel, 'active', (string)($task['state'] ?? 'blocked'), (string)($task['reason'] ?? $reason)),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
protected function taskUsesProgramPicker(array $task): bool
|
||||
{
|
||||
if (in_array(
|
||||
'PROGRAM_PICKER',
|
||||
$this->normalizeServiceNames($this->normalizeJsonArray($task['services'] ?? null)),
|
||||
true
|
||||
)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array('program_picker', $this->normalizeButtonList($task['buttons'] ?? null), true);
|
||||
}
|
||||
|
||||
protected function isProgramNumberButton(mixed $button): bool
|
||||
{
|
||||
return is_int($button) && $button >= 0 && $button <= 11;
|
||||
}
|
||||
|
||||
protected function dynamicImageButtonSequenceForTask(array $task): array
|
||||
{
|
||||
$buttons = $this->normalizeButtonList($task['buttons'] ?? null);
|
||||
if (!$this->taskUsesProgramPicker($task)) {
|
||||
return $buttons;
|
||||
}
|
||||
|
||||
$sequence = ['program_picker'];
|
||||
foreach ($buttons as $button) {
|
||||
if ($button === 'program_picker' || $this->isProgramNumberButton($button)) {
|
||||
continue;
|
||||
}
|
||||
$sequence[] = $button;
|
||||
}
|
||||
|
||||
return $this->normalizeButtonList($sequence);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $questions
|
||||
* @param array<int,array<string,mixed>> $conditions
|
||||
* @param array<int,array<string,mixed>> $rules
|
||||
* @param array<int,array<string,mixed>> $tasks
|
||||
* @param array<int,array<string,mixed>> $actions
|
||||
* @param array<int,array<string,mixed>> $signals
|
||||
* @param array<int,array<string,mixed>> $dynamicImageButtons
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
protected function buildDebugDecisions(array $questions, array $conditions, array $rules, array $tasks, array $actions, array $signals, array $dynamicImageButtons): array
|
||||
{
|
||||
$decisions = [];
|
||||
foreach ([
|
||||
'question' => $questions,
|
||||
'condition' => $conditions,
|
||||
'rule' => $rules,
|
||||
'task' => $tasks,
|
||||
'action' => $actions,
|
||||
'signal' => $signals,
|
||||
'dynamic_image_button' => $dynamicImageButtons,
|
||||
] as $kind => $items) {
|
||||
foreach ($items as $item) {
|
||||
if (is_array($item)) {
|
||||
$decisions[] = $this->debugDecisionFromItem($kind, $item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $decisions;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $item
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
protected function debugDecisionFromItem(string $kind, array $item): array
|
||||
{
|
||||
$nodeIds = [];
|
||||
foreach ((array)($item['node_ids'] ?? []) as $nodeId) {
|
||||
if (is_string($nodeId) && $nodeId !== '') {
|
||||
$nodeIds[] = $nodeId;
|
||||
}
|
||||
}
|
||||
if ($nodeIds === [] && isset($item['node_id']) && is_string($item['node_id']) && $item['node_id'] !== '') {
|
||||
$nodeIds[] = $item['node_id'];
|
||||
}
|
||||
|
||||
$state = (string)($item['state'] ?? '');
|
||||
if ($state === '') {
|
||||
$state = match ($kind) {
|
||||
'condition' => (($item['result'] ?? false) === true ? 'passed' : 'failed'),
|
||||
'rule' => (($item['satisfied'] ?? false) === true ? 'passed' : 'failed'),
|
||||
'task', 'action' => (($item['active'] ?? false) === true ? 'active' : 'skipped'),
|
||||
'signal' => (string)($item['predicted_status'] ?? 'unknown'),
|
||||
default => 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
$causes = [];
|
||||
foreach ((array)($item['causes'] ?? []) as $cause) {
|
||||
if (is_array($cause)) {
|
||||
$causes[] = $cause;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'kind' => (string)($item['kind'] ?? $kind),
|
||||
'id' => $item['id'] ?? ($item['node_id'] ?? null),
|
||||
'label' => (string)($item['label'] ?? $item['title'] ?? $item['node_id'] ?? $kind),
|
||||
'state' => $state,
|
||||
'reason' => (string)($item['reason'] ?? ''),
|
||||
'node_ids' => array_values(array_unique($nodeIds)),
|
||||
'causes' => $causes,
|
||||
];
|
||||
}
|
||||
|
||||
protected function debugDynamicImageButtonLabel(mixed $button): string
|
||||
{
|
||||
if (is_array($button)) {
|
||||
foreach (['label', 'name', 'title', 'button', 'id', 'value'] as $key) {
|
||||
if (isset($button[$key]) && trim((string)$button[$key]) !== '') {
|
||||
return (string)$button[$key];
|
||||
}
|
||||
}
|
||||
$encoded = json_encode($button, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
return $encoded === false ? 'Button' : $encoded;
|
||||
}
|
||||
$label = trim((string)$button);
|
||||
if (strtolower($label) === 'program_picker') {
|
||||
return 'Program picker';
|
||||
}
|
||||
|
||||
return $label === '' ? 'Button' : $label;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<string,mixed>> $conditions
|
||||
* @param array<int,array<string,mixed>> $rules
|
||||
@@ -2213,6 +2694,26 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
return $failed;
|
||||
}
|
||||
}
|
||||
foreach (['when', 'then', 'default'] as $field) {
|
||||
if (!is_array($trace[$field] ?? null)) {
|
||||
continue;
|
||||
}
|
||||
$failed = $this->firstFailedPredicateTrace((array)$trace[$field]);
|
||||
if ($failed !== null) {
|
||||
return $failed;
|
||||
}
|
||||
}
|
||||
foreach (['branches', 'cases'] as $field) {
|
||||
foreach ((array)($trace[$field] ?? []) as $child) {
|
||||
if (!is_array($child)) {
|
||||
continue;
|
||||
}
|
||||
$failed = $this->firstFailedPredicateTrace((array)$child);
|
||||
if ($failed !== null) {
|
||||
return $failed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2498,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 (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;
|
||||
}));
|
||||
}
|
||||
@@ -2541,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 (int)($condition['machine_type_id'] ?? 0) === 0
|
||||
&& (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;
|
||||
}));
|
||||
}
|
||||
@@ -2585,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 (int)($task['machine_type_id'] ?? 0) === 0
|
||||
&& (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;
|
||||
}));
|
||||
}
|
||||
@@ -2724,6 +3225,86 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
|
||||
(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
|
||||
{
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,5 +5,6 @@ namespace modules\selfserve\helpers;
|
||||
enum selfserve_lane_services
|
||||
{
|
||||
case MACHINE; // Relay that controls the machine power
|
||||
case PROGRAM_PICKER; // Relay that controls the machine program picker
|
||||
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ interface selfserve_wash_flow_i
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ Purpose: manage the task list shown after eligibility evaluation.
|
||||
Notes:
|
||||
|
||||
- Route parameter name is `condition_id`. That value is used as the task gate id.
|
||||
- `services` accepts an array, JSON array string, or comma-separated string. The only current enum case is `MACHINE`.
|
||||
- `services` accepts an array, JSON array string, or comma-separated string. Current enum cases are `MACHINE` and `PROGRAM_PICKER`.
|
||||
- If an active task does not expose `MACHINE`, the relay will not be enabled.
|
||||
|
||||
Typical failures:
|
||||
@@ -356,15 +356,15 @@ Purpose: operational lane control and relay management.
|
||||
| Method | Required params | Permissions | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET /modules/self-serve/lane/status` | optional `lane_id`, default `1` | `modules_selfserve_lane_status_view` | Returns lane status, mode, state, wash timer, reg, and customer number. |
|
||||
| `POST /modules/self-serve/lane/command` | `lane_id`, `command` | `modules_selfserve_lane_command_execute` plus command-specific permission | Valid commands: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`. |
|
||||
| `POST /modules/self-serve/lane/services/allowed` | `lane_id`, optional `task_ids` | `modules_selfserve_lane_services_set_allowed` | Writes allowed service names to the lane cache. |
|
||||
| `POST /modules/self-serve/lane/command` | `lane_id`, `command` | `modules_selfserve_lane_command_execute` plus command-specific permission, or customer `list_own_department_selfserve_vehicle_conditions` for scoped `START`, scoped `STOP`, and property gate commands | Valid commands: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`, `OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`. Customer `START` requires an enabled self-serve lane. Customer `STOP` and property gate commands require the customer's active wash in the lane department. |
|
||||
| `POST /modules/self-serve/lane/services/allowed` | `lane_id`, optional `task_ids` | `modules_selfserve_lane_services_set_allowed`, or customer `list_own_department_selfserve_vehicle_conditions` on an enabled self-serve lane | Writes allowed service names to the lane cache. This is still read-from-visible-tasks only; it does not activate relays. |
|
||||
| `GET /modules/self-serve/lane/relay/machine_program_picker/status` | `lane_id` | `modules_selfserve_lane_relay_machine_program_picker_status_view` | Reads the Shelly MACHINE_PROGRAM_PICKER relay state (`on`/`off`) for the lane. |
|
||||
| `POST /modules/self-serve/lane/relay/machine_program_picker/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_program_picker_status_set` | Sets Shelly MACHINE_PROGRAM_PICKER relay state directly (`on=true/false`) and returns updated status. |
|
||||
| `GET /modules/self-serve/lane/relay/machine_cleaner/status` | `lane_id` | `modules_selfserve_lane_relay_machine_cleaner_status_view` | Reads the Shelly MACHINE_CLEANER relay state (`on`/`off`) for the lane. |
|
||||
| `POST /modules/self-serve/lane/relay/machine_cleaner/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_cleaner_status_set` | Sets Shelly MACHINE_CLEANER relay state directly (`on=true/false`) and returns updated status. |
|
||||
| `GET /modules/self-serve/lane/relay/machine/status` | `lane_id` | `modules_selfserve_lane_relay_machine_status_view` | Reads the Shelly MACHINE relay state (`on`/`off`) for the lane. |
|
||||
| `POST /modules/self-serve/lane/relay/machine/set` | `lane_id`, `on` | `modules_selfserve_lane_relay_machine_status_set` | Sets Shelly MACHINE relay state directly (`on=true/false`) and returns updated status. |
|
||||
| `POST /modules/self-serve/lane/relay/machine/enable` | `lane_id`, optional `duration` | `modules_selfserve_lane_relay_enable_machine` | Manual enable, still gated by allowed services. |
|
||||
| `POST /modules/self-serve/lane/relay/machine/enable` | `lane_id`, optional `duration` | `modules_selfserve_lane_relay_enable_machine`, or customer `list_own_department_selfserve_vehicle_conditions` with an active wash in the lane department | Manual enable, still gated by allowed services. Customer flow calls this only after `START` and only when `MACHINE` is allowed. |
|
||||
| `POST /modules/self-serve/lane/force/machine/enable` | `lane_id`, optional `duration`, optional `license_plate` | `modules_selfserve_lane_force_machine_enable` | Bypasses service gating and marks the lane as in wash. |
|
||||
| `POST /modules/self-serve/lane/force/machine/disable` | `lane_id`, optional `license_plate` | `modules_selfserve_lane_force_machine_disable` | Keeps the lane in wash but turns the machine relay off. |
|
||||
|
||||
@@ -382,6 +382,12 @@ STOP flow details:
|
||||
- Unless bypass is enabled, the lane customer number must match the current authenticated user's customer number.
|
||||
- STOP calls `invoice()`, opens the exit port, turns off the machine relay if self-serve is enabled for the department, completes the latest open self-serve session, and then resets the lane.
|
||||
|
||||
Customer start-wash release checklist:
|
||||
|
||||
- Canary hardware validation must use the department and lane configured for the live Playwright/release credentials. Record the exact `department_id` and `lane_id` in the release notes before the live run.
|
||||
- Verify the self-serve module is enabled, department self-serve is enabled, the target lane has `selfserve_enabled=1`, relays and property gates are bound, minute billing product is configured, and the machine task exposes the `MACHINE` service before promoting canary.
|
||||
- Validate one supervised real-lane manual wash and, when configured, one machine wash before stable promotion. Confirm no relay changes before customer confirmation, active wash restore works across reloads, property gates open only during the active wash, `STOP` completes the session, and billing/order linkage is present.
|
||||
|
||||
Typical failures:
|
||||
|
||||
- `400` invalid parameters
|
||||
@@ -433,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. |
|
||||
| `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. |
|
||||
| `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:
|
||||
|
||||
@@ -480,7 +486,7 @@ Important enums:
|
||||
- `selfserve_lane_command`: `START`, `STOP`, `RESET`, `RESERVE`, `RELEASE`
|
||||
- `selfserve_lane_status`: `AVAILABLE`, `OCCUPIED`, `RESERVED`, `FAULT`, `MAINTENANCE`, `CLOSED`
|
||||
- `selfserve_lane_state`: `IDLE`, `IN_WASH`, relay states, gate states, and fault states
|
||||
- `selfserve_lane_services`: currently only `MACHINE`
|
||||
- `selfserve_lane_services`: currently `MACHINE` and `PROGRAM_PICKER`
|
||||
|
||||
### Machine Type And Wash Session Objects
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ namespace modules\selfserve;
|
||||
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_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_machine_wash_minutes_included_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
|
||||
*/
|
||||
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()
|
||||
{
|
||||
@@ -36,10 +43,12 @@ class selfserve_c
|
||||
$this->allowUpdate([
|
||||
selfserve_enabled_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->minute_product = new selfserve_minute_product_c();
|
||||
$this->machine_wash_minutes_included = new selfserve_machine_wash_minutes_included_c();
|
||||
$this->dynamic_image_size = new selfserve_dynamic_image_size_c();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ trait selfserve_lane_cache_t
|
||||
{
|
||||
const CACHE_SELFSERVE_PREFIX = 'selfserve_lane_';
|
||||
const CACHE_SELFSERVE_LANE_KEY_STATUS = self::CACHE_SELFSERVE_PREFIX . 'status';
|
||||
const CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT = self::CACHE_SELFSERVE_PREFIX . 'status_audit';
|
||||
const CACHE_SELFSERVE_LANE_KEY_STATE = self::CACHE_SELFSERVE_PREFIX . 'state';
|
||||
const CACHE_SELFSERVE_LANE_KEY_MODE = self::CACHE_SELFSERVE_PREFIX . 'mode';
|
||||
const CACHE_SELFSERVE_LANE_KEY_WASH_START_TIME = self::CACHE_SELFSERVE_PREFIX . 'wash_start_time';
|
||||
@@ -77,4 +78,4 @@ trait selfserve_lane_cache_t
|
||||
redis->delete($this->getLaneCacheKey($laneId, $property));
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,49 @@ use objects\department_variables_o;
|
||||
|
||||
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.
|
||||
* 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.
|
||||
* 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)) {
|
||||
return;
|
||||
}
|
||||
$active_wash = new selfserve_wash_flow();
|
||||
if ($active_wash->isMachineAllowedToStartWash($this->id)) {
|
||||
if ($this->isMachineWashSelectedAndAvailableForStart($arguments)) {
|
||||
try {
|
||||
$this->setMachineRelayStatusHard(true);
|
||||
} 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
|
||||
{
|
||||
try {
|
||||
@@ -231,30 +383,26 @@ trait selfserve_lane_command_t
|
||||
protected function runRelaySideEffectsForWashStart(selfserve_lane_command_arguments $arguments): void
|
||||
{
|
||||
if ($arguments->defer_relay_side_effects) {
|
||||
$this->setProgramPickerRelayStatusFromSelectedServiceForWashStart($arguments);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure cleaner relay is enabled whenever wash starts.
|
||||
$this->turnOnCleanerRelayForWashStart();
|
||||
// Ensure the machine relay is ON when a wash starts, when it is allowed.
|
||||
$this->setMachineRelayStatusForWashStart();
|
||||
$this->setProgramPickerRelayStatusForWashStart($arguments);
|
||||
$this->setMachineRelayStatusForWashStart($arguments);
|
||||
}
|
||||
|
||||
protected function resolveSelfServeActionWashModeForStart(): string
|
||||
protected function resolveSelfServeActionWashModeForStart(?selfserve_lane_command_arguments $arguments = null): string
|
||||
{
|
||||
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 selfserve_studio_actions::MODE_MACHINE;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Fall through to manual mode when the cached service set is unavailable.
|
||||
if ($arguments !== null && in_array($arguments->wash_mode, [
|
||||
selfserve_studio_actions::MODE_MANUAL,
|
||||
selfserve_studio_actions::MODE_MACHINE,
|
||||
], true)) {
|
||||
return $arguments->wash_mode;
|
||||
}
|
||||
|
||||
if ($this->isMachineServiceSelectedForWashStart()) {
|
||||
return selfserve_studio_actions::MODE_MACHINE;
|
||||
}
|
||||
|
||||
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
|
||||
* 2. Machine relay
|
||||
* 2. Program picker relay
|
||||
* 3. Machine relay
|
||||
*/
|
||||
protected function turnOffRelaysAfterStop(): void
|
||||
{
|
||||
$relays = [
|
||||
selfserve_lane_relay::MACHINE_CLEANER,
|
||||
selfserve_lane_relay::MACHINE_PROGRAM_PICKER,
|
||||
selfserve_lane_relay::MACHINE,
|
||||
];
|
||||
|
||||
@@ -388,7 +538,8 @@ trait selfserve_lane_command_t
|
||||
$this->id,
|
||||
$this->getLicensePlate() ?: null,
|
||||
$this->getCustomerNumber() ?: null,
|
||||
method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null
|
||||
method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null,
|
||||
false
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
// Session completion must not block STOP flow.
|
||||
@@ -464,6 +615,7 @@ trait selfserve_lane_command_t
|
||||
* @param selfserve_lane_command_arguments $arguments The arguments for the command
|
||||
* @return selfserve_lane|selfserve_lane_command_t
|
||||
* @throws Exception If the command cannot be executed
|
||||
* @throws \Throwable
|
||||
*/
|
||||
public function execute(selfserve_lane_command $command, selfserve_lane_command_arguments $arguments): self
|
||||
{
|
||||
@@ -508,38 +660,48 @@ trait selfserve_lane_command_t
|
||||
// Validate 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);
|
||||
$previous_customer_number = $this->getCustomerNumber();
|
||||
$previous_license_plate = $this->getLicensePlate();
|
||||
// Set the customer number and license plate
|
||||
$this->setCustomerNumber($customer_number);
|
||||
$this->setLicensePlate($license_plate);
|
||||
$start_lock_token = $this->acquireLaneStartCommandLock();
|
||||
try {
|
||||
// Open the entrance port before marking the lane occupied. Gateway timeouts are
|
||||
// ambiguous because the relay may already have received the pulse.
|
||||
$this->openEntrancePortForWashStart();
|
||||
} catch (\Throwable $e) {
|
||||
$this->setCustomerNumber($previous_customer_number);
|
||||
$this->setLicensePlate($previous_license_plate);
|
||||
$this->setLaneState(selfserve_lane_state::IDLE);
|
||||
throw $e;
|
||||
// Re-check availability after taking the START lock so concurrent requests cannot
|
||||
// both pass the preflight check and trigger the physical entrance relay.
|
||||
if (!$this->getLaneStatus()->equals(selfserve_lane_status::AVAILABLE)) throw new \RuntimeException("Cannot start lane: Lane is not available.");
|
||||
$previous_customer_number = $this->getCustomerNumber();
|
||||
$previous_license_plate = $this->getLicensePlate();
|
||||
$previous_status = $this->getLaneStatus();
|
||||
// Set the customer number and license plate
|
||||
$this->setCustomerNumber($customer_number);
|
||||
$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;
|
||||
case selfserve_lane_command::STOP:
|
||||
// Require lane to be occupied before stopping
|
||||
@@ -550,6 +712,8 @@ trait selfserve_lane_command_t
|
||||
}
|
||||
// Snapshot the physical machine ON signal before session completion/reset.
|
||||
$machine_start_triggered = $this->hasMachineStartSignalForStop();
|
||||
// Turn off relays before any configured or default exit gate opens.
|
||||
$this->turnOffRelaysAfterStop();
|
||||
$this->runPublishedStudioActions(
|
||||
selfserve_studio_actions::EVENT_WASH_STOP_COMMAND,
|
||||
$machine_start_triggered ? selfserve_studio_actions::MODE_MACHINE : selfserve_studio_actions::MODE_MANUAL,
|
||||
@@ -562,12 +726,10 @@ trait selfserve_lane_command_t
|
||||
// Open the exit port. Gateway timeouts are ambiguous because
|
||||
// the relay may already have received the pulse.
|
||||
$this->openExitPortForWashStop();
|
||||
// Turn off relays in deterministic order after STOP
|
||||
$this->turnOffRelaysAfterStop();
|
||||
// Log the lane stop event
|
||||
$this->logLaneAction(selfserve_lane_log_action::STOP_WASH);
|
||||
// Invoice the customer
|
||||
$this->invoice();
|
||||
$this->invoice($arguments);
|
||||
// Only bill the machine wash product when the physical machine start signal was recorded.
|
||||
$this->addVehicleTypeProductToInvoiceIfNeeded($machine_start_triggered);
|
||||
// Finalize any active self-serve wash session for this lane
|
||||
|
||||
@@ -4,15 +4,24 @@ namespace modules\selfserve\traits;
|
||||
require_once WD . '/modules/selfserve/helpers/selfserve_lane_status.php';
|
||||
require_once WD . '/modules/selfserve/helpers/selfserve_lane_mode.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_lane.php';
|
||||
require_once WD . '/modules/selfserve/classes/selfserve_lane_command_arguments.php';
|
||||
require_once WD . '/modules/attachments/helpers/attachment_content.php';
|
||||
require_once WD . '/objects/selfserve_wash_sessions_o.php';
|
||||
require_once WD . '/objects/subusers_o.php';
|
||||
|
||||
use classes\selfserve;
|
||||
use classes\economic;
|
||||
use Exception;
|
||||
use attachments\helpers\attachment_content;
|
||||
use modules\selfserve\classes\selfserve_lane_command_arguments;
|
||||
use modules\selfserve\classes\selfserve_lane;
|
||||
use modules\selfserve\helpers\selfserve_lane_mode;
|
||||
use modules\selfserve\helpers\selfserve_lane_status;
|
||||
use objects\customer_vehicles_o;
|
||||
use objects\order_items_o;
|
||||
use objects\orders_o;
|
||||
use objects\selfserve_wash_sessions_o;
|
||||
use objects\subusers_o;
|
||||
|
||||
trait selfserve_lane_invoice_t
|
||||
{
|
||||
@@ -102,7 +111,7 @@ trait selfserve_lane_invoice_t
|
||||
* @return bool True on success, false on failure
|
||||
* @throws Exception if lane ID is not set, lane is not occupied, customer number or license plate is not set, or product ID is not set
|
||||
*/
|
||||
public function invoice(): bool
|
||||
public function invoice(?selfserve_lane_command_arguments $arguments = null): bool
|
||||
{
|
||||
$this->last_invoice_order_id = null;
|
||||
|
||||
@@ -115,12 +124,10 @@ trait selfserve_lane_invoice_t
|
||||
$included_minutes = $this->resolveIncludedMinutesForBilling();
|
||||
$billable_minutes = $this->calculateBillableMinutes($elapsed_minutes, $included_minutes);
|
||||
|
||||
if ($billable_minutes <= 0) {
|
||||
return true;
|
||||
if ($billable_minutes > 0) {
|
||||
$order = $this->createInvoiceOrderContext($arguments);
|
||||
$this->billable_minutes_order_item = $this->addMinuteBillingLine((int)$order->id, (int)$product_id, $billable_minutes);
|
||||
}
|
||||
|
||||
$order = $this->createInvoiceOrderContext();
|
||||
$this->billable_minutes_order_item = $this->addMinuteBillingLine((int)$order->id, (int)$product_id, $billable_minutes);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -212,10 +219,12 @@ trait selfserve_lane_invoice_t
|
||||
return max(0, $elapsed_minutes - $included_minutes);
|
||||
}
|
||||
|
||||
protected function createInvoiceOrderContext(): orders_o
|
||||
protected function createInvoiceOrderContext(?selfserve_lane_command_arguments $arguments = null): orders_o
|
||||
{
|
||||
$billing_customer_number = $this->getCustomerNumber();
|
||||
$draft_customer_number = (new economic())->getTransactionDraftCustomerNumber();
|
||||
$order = (new orders_o())->add(
|
||||
$this->getCustomerNumber(),
|
||||
$billing_customer_number,
|
||||
self::INVOICE_SYSTEM_USER_ID,
|
||||
'',
|
||||
'',
|
||||
@@ -224,10 +233,101 @@ trait selfserve_lane_invoice_t
|
||||
);
|
||||
$order->lane->set($this->id);
|
||||
$this->last_invoice_order_id = (int)$order->id;
|
||||
$this->attachSelfServeMetadataToOrder($order, $billing_customer_number, $draft_customer_number, $arguments);
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
protected function attachSelfServeMetadataToOrder(
|
||||
orders_o $order,
|
||||
int $billing_customer_number,
|
||||
?int $draft_customer_number,
|
||||
?selfserve_lane_command_arguments $arguments = null
|
||||
): void {
|
||||
try {
|
||||
$order->addAttachment(
|
||||
(new attachment_content())->setOther(
|
||||
$this->buildSelfServeOrderAttachmentPayload($billing_customer_number, $draft_customer_number, $arguments)
|
||||
)
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
// Metadata attachments must not block billing; the order itself is the source of record.
|
||||
}
|
||||
}
|
||||
|
||||
protected function buildSelfServeOrderAttachmentPayload(
|
||||
int $billing_customer_number,
|
||||
?int $draft_customer_number,
|
||||
?selfserve_lane_command_arguments $arguments = null
|
||||
): array {
|
||||
$session = $this->findOpenSelfServeSessionForAttachment($billing_customer_number);
|
||||
$subuser_id = $arguments?->subuser_id;
|
||||
|
||||
return [
|
||||
'type' => attachment_content::OTHER_TYPE_SELF_SERVE_WASH,
|
||||
'source' => 'selfserve',
|
||||
'customer_number' => $billing_customer_number,
|
||||
'draft_customer_number' => $draft_customer_number,
|
||||
'subuser_id' => $subuser_id,
|
||||
'subuser' => $this->formatSelfServeAttachmentSubuser($subuser_id),
|
||||
'session_id' => $session?->id,
|
||||
'lane_id' => (int)$this->id,
|
||||
'department_id' => (int)$this->department_lane->department->value(),
|
||||
'license_plate' => (string)$this->getLicensePlate(),
|
||||
'lane_status' => $this->getLaneStatus()->name,
|
||||
'lane_mode' => $this->getLaneMode()->name,
|
||||
'wash_start_time' => (int)$this->getWashStartTime(),
|
||||
'elapsed_wash_time_seconds' => (int)$this->getElapsedWashTime(),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function findOpenSelfServeSessionForAttachment(int $billing_customer_number): ?selfserve_wash_sessions_o
|
||||
{
|
||||
$license_plate = trim((string)$this->getLicensePlate());
|
||||
if ($license_plate === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$session = (new selfserve_wash_sessions_o())->selectLatestOpenByLaneAndReg(
|
||||
(int)$this->id,
|
||||
selfserve::standardize_registration($license_plate),
|
||||
$billing_customer_number > 0 ? $billing_customer_number : null
|
||||
);
|
||||
return $session->exists() ? $session : null;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected function formatSelfServeAttachmentSubuser(?int $subuser_id): ?array
|
||||
{
|
||||
if ($subuser_id === null || $subuser_id <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$subuser = (new subusers_o())->select($subuser_id);
|
||||
if (!$subuser->exists()) {
|
||||
return [
|
||||
'id' => $subuser_id,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int)$subuser->id,
|
||||
'name' => $subuser->name->value(),
|
||||
'username' => $subuser->username->value(),
|
||||
'email' => $subuser->email->value(),
|
||||
];
|
||||
} catch (\Throwable) {
|
||||
return [
|
||||
'id' => $subuser_id,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
protected function addMinuteBillingLine(int $order_id, int $product_id, int $quantity): order_items_o
|
||||
{
|
||||
return (new order_items_o())->addItemToOrder(
|
||||
|
||||
@@ -17,6 +17,7 @@ trait selfserve_lane_port_controller_t
|
||||
{
|
||||
private const DEMO_RELAY_ID_PREFIX = 'demo-';
|
||||
private const DEFAULT_PORT_OPEN_TOGGLE_AFTER_SECONDS = 1;
|
||||
private const MAX_PORT_OPEN_TOGGLE_AFTER_SECONDS = 5;
|
||||
|
||||
/**
|
||||
* Open the lane port
|
||||
@@ -105,7 +106,7 @@ trait selfserve_lane_port_controller_t
|
||||
private function normalizePortOpenToggleAfter(?int $toggle_after_seconds): int
|
||||
{
|
||||
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;
|
||||
|
||||
@@ -12,6 +12,7 @@ use modules\selfserve\helpers\selfserve_lane_relay;
|
||||
use modules\selfserve\helpers\selfserve_lane_services;
|
||||
use modules\selfserve\helpers\selfserve_lane_status;
|
||||
use modules\shelly\helpers\shelly_request_body_get_states;
|
||||
use objects\selfserve_wash_sessions_o;
|
||||
|
||||
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
|
||||
* @return bool
|
||||
* @throws \Exception
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -87,4 +87,44 @@ trait selfserve_lane_status_t
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
public function setLaneStatusAudit(?array $audit): self
|
||||
{
|
||||
if ($audit === null) {
|
||||
$this->clearLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT);
|
||||
return $this;
|
||||
}
|
||||
|
||||
$this->setLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT, [
|
||||
'modified_at' => isset($audit['modified_at']) ? (string)$audit['modified_at'] : date(DATE_ATOM),
|
||||
'modified_by_user_id' => isset($audit['modified_by_user_id']) ? (int)$audit['modified_by_user_id'] : null,
|
||||
'modified_by_name' => isset($audit['modified_by_name']) ? (string)$audit['modified_by_name'] : null,
|
||||
]);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLaneStatusAudit(): ?array
|
||||
{
|
||||
$audit = $this->getLaneCache($this->id, self::CACHE_SELFSERVE_LANE_KEY_STATUS_AUDIT);
|
||||
if (!is_array($audit)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$modified_at = isset($audit['modified_at']) ? trim((string)$audit['modified_at']) : '';
|
||||
$modified_by_name = isset($audit['modified_by_name']) ? trim((string)$audit['modified_by_name']) : '';
|
||||
$modified_by_user_id = isset($audit['modified_by_user_id']) && is_numeric($audit['modified_by_user_id'])
|
||||
? (int)$audit['modified_by_user_id']
|
||||
: null;
|
||||
|
||||
if ($modified_at === '' && $modified_by_name === '' && $modified_by_user_id === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'modified_at' => $modified_at !== '' ? $modified_at : null,
|
||||
'modified_by_user_id' => $modified_by_user_id,
|
||||
'modified_by_name' => $modified_by_name !== '' ? $modified_by_name : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// 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
|
||||
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
|
||||
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");
|
||||
// If the certificate was uploaded successfully, delete the local copy
|
||||
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
|
||||
echo $wash_certificate_store->getWashCertificateDownload($_GET['bookingId']);
|
||||
exit;
|
||||
@@ -131,7 +131,7 @@ $generatedCertificatePath = "output/certificates/wash_certificate_" . $_GET['boo
|
||||
$wash_certificate_store->uploadFile($generatedCertificateName, dirname(__FILE__) . '/' . $generatedCertificatePath);
|
||||
|
||||
// Delete the local copy of the certificate
|
||||
//unlink(dirname(__FILE__) . '/' . $generatedCertificatePath);
|
||||
unlink(dirname(__FILE__) . '/' . $generatedCertificatePath);
|
||||
|
||||
// Set the status of the booking to completed
|
||||
$booking = new bookings_o();
|
||||
|
||||
@@ -7,7 +7,14 @@ use traits\module_config_variable;
|
||||
|
||||
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
|
||||
@@ -20,10 +27,54 @@ class workfeed_api_url_c
|
||||
'string',
|
||||
true,
|
||||
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',
|
||||
false,
|
||||
'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);
|
||||
$washCertificateUrl = $db->escape_string($washCertificateUrl);
|
||||
$status = $db->escape_string($status);
|
||||
$pickup_bool = (int)$pickup_bool;
|
||||
// Check if the entry already exists
|
||||
$sql = "SELECT * FROM $this->table WHERE id = $id";
|
||||
$result = $db->query($sql);
|
||||
|
||||
@@ -606,6 +606,76 @@ class collected_order_invoices_o extends db
|
||||
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
|
||||
* @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 $deleted_at;
|
||||
const TOKEN_LENGTH = 32;
|
||||
const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour
|
||||
const TOKEN_EXPIRY_SECONDS = 72 * 60 * 60; // 72 hours
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
$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
|
||||
@@ -65,9 +70,8 @@ class customer_password_reset_keys_o extends db
|
||||
if (strlen($token) !== self::TOKEN_LENGTH) {
|
||||
return null;
|
||||
}
|
||||
// Query the database for a valid token
|
||||
$current_time = date('Y-m-d H:i:s');
|
||||
$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";
|
||||
// Query the database for a valid token using the same clock that writes created_at.
|
||||
$sql = "SELECT id FROM $this->table WHERE token = '" . $db->escape_string($token) . "' AND " . $this->validTokenWhereClause() . " LIMIT 1";
|
||||
$result = $db->query($sql);
|
||||
if ($result->num_rows === 0) {
|
||||
return null;
|
||||
@@ -85,13 +89,11 @@ class customer_password_reset_keys_o extends db
|
||||
*/
|
||||
public function isValidToken(): bool
|
||||
{
|
||||
global $db;
|
||||
self::requireSelected();
|
||||
$created_at = strtotime($this->created_at->value());
|
||||
$current_time = time();
|
||||
return (
|
||||
($current_time - $created_at) <= self::TOKEN_EXPIRY_SECONDS) &&
|
||||
($this->deleted_at->value() === null
|
||||
);
|
||||
$sql = "SELECT id FROM $this->table WHERE id = " . (int)$this->id . " AND " . $this->validTokenWhereClause() . " LIMIT 1";
|
||||
$result = $db->query($sql);
|
||||
return $result->num_rows > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,4 +142,4 @@ class customer_password_reset_keys_o extends db
|
||||
{
|
||||
//TODO: Add cache invalidation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use classes\object_property;
|
||||
use classes\selfserve;
|
||||
use classes\selfserve_schema_bootstrap;
|
||||
use Exception;
|
||||
use modules\selfserve\classes\selfserve_config_versioning;
|
||||
use traits\db_object_t;
|
||||
|
||||
class department_lanes_o extends db
|
||||
@@ -130,6 +131,10 @@ class department_lanes_o extends db
|
||||
|
||||
public function asArray(): array
|
||||
{
|
||||
$status = (string)$this->getLaneStatus()->name;
|
||||
$machine_status_audit = $this->getMachineStatusAudit();
|
||||
$selfserve_configuration_warnings = $this->getSelfServeConfigurationWarnings();
|
||||
|
||||
return [
|
||||
'id' => (int)$this->id,
|
||||
'department' => (int)$this->department->value(),
|
||||
@@ -143,13 +148,76 @@ class department_lanes_o extends db
|
||||
'machine_type_id' => (function($v){ return $v === null ? null : (int)$v; })($this->machine_type_id->value()),
|
||||
'selfserve_enabled' => $this->isSelfServeEnabled(),
|
||||
// Status of the lane
|
||||
'status' => (string)$this->getLaneStatus()->name,
|
||||
'status' => $status,
|
||||
'machine_status_enabled' => self::isOperationalStatusName($status),
|
||||
'machine_status_audit' => $machine_status_audit,
|
||||
'machine_status_modified_at' => $machine_status_audit['modified_at'] ?? null,
|
||||
'machine_status_modified_by' => $machine_status_audit['modified_by_name'] ?? null,
|
||||
'machine_status_modified_by_user_id' => $machine_status_audit['modified_by_user_id'] ?? null,
|
||||
'selfserve_configured' => $selfserve_configuration_warnings === [],
|
||||
'dognvask_configured' => $selfserve_configuration_warnings === [],
|
||||
'dognvask_configuration_warnings' => $selfserve_configuration_warnings,
|
||||
// Timestamps
|
||||
'created_at' => (string)$this->created_at->value(),
|
||||
'updated_at' => (string)$this->updated_at->value(),
|
||||
];
|
||||
}
|
||||
|
||||
private function getMachineStatusAudit(): ?array
|
||||
{
|
||||
try {
|
||||
$lane = (new selfserve())->lane((int)$this->id);
|
||||
if (!method_exists($lane, 'getLaneStatusAudit')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$audit = $lane->getLaneStatusAudit();
|
||||
return is_array($audit) ? $audit : null;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function getSelfServeConfigurationWarnings(): array
|
||||
{
|
||||
self::requireSelected();
|
||||
|
||||
$required_fields = [
|
||||
'relay_in_id' => 'Indgangsrelæ',
|
||||
'relay_out_id' => 'Udgangsrelæ',
|
||||
'relay_machine_id' => 'Maskinrelæ',
|
||||
'relay_machine_program_picker_id' => 'Programvælgerrelæ',
|
||||
'relay_machine_cleaner_id' => 'Vaskerelæ',
|
||||
'dynamic_image_id' => 'Maskinstatusbillede',
|
||||
'machine_type_id' => 'Maskintype',
|
||||
];
|
||||
|
||||
$warnings = [];
|
||||
foreach ($required_fields as $field => $label) {
|
||||
if ($this->hasConfiguredFieldValue($field)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$warnings[] = [
|
||||
'field' => $field,
|
||||
'label' => $label,
|
||||
'message' => $label . ' mangler',
|
||||
];
|
||||
}
|
||||
|
||||
return $warnings;
|
||||
}
|
||||
|
||||
public function isSelfServeConfigured(): bool
|
||||
{
|
||||
return $this->getSelfServeConfigurationWarnings() === [];
|
||||
}
|
||||
|
||||
public static function isOperationalStatusName(string $status): bool
|
||||
{
|
||||
return in_array(strtoupper(trim($status)), ['AVAILABLE', 'OCCUPIED', 'RESERVED'], true);
|
||||
}
|
||||
|
||||
public function isSelfServeEnabled(): bool
|
||||
{
|
||||
self::requireSelected();
|
||||
@@ -184,6 +252,29 @@ class department_lanes_o extends db
|
||||
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
private function hasConfiguredFieldValue(string $field): bool
|
||||
{
|
||||
if (!isset($this->{$field}) || !is_object($this->{$field}) || !method_exists($this->{$field}, 'value')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$value = $this->{$field}->value();
|
||||
if ($value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
$value = trim($value);
|
||||
return $value !== '' && $value !== '0' && strtolower($value) !== 'null';
|
||||
}
|
||||
|
||||
if (is_numeric($value)) {
|
||||
return (int)$value > 0;
|
||||
}
|
||||
|
||||
return (bool)$value;
|
||||
}
|
||||
|
||||
public static function disableSelfServeRelaysBestEffort(int $lane_id): void
|
||||
{
|
||||
if ($lane_id <= 0) {
|
||||
@@ -234,9 +325,52 @@ class department_lanes_o extends db
|
||||
public function getSelfServeLaneProducts(): array
|
||||
{
|
||||
self::requireSelected();
|
||||
$publishedProducts = $this->getPublishedSelfServeLaneProducts();
|
||||
if ($publishedProducts !== []) {
|
||||
return $publishedProducts;
|
||||
}
|
||||
|
||||
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
|
||||
* @return department_lanes_o[] An array of department lane objects for the specified department
|
||||
|
||||
@@ -386,7 +386,7 @@ class department_selfserve_tasks_o extends db
|
||||
if (is_string($btn)) {
|
||||
$trimmed = trim($btn);
|
||||
$specialButton = strtolower($trimmed);
|
||||
if ($specialButton === 'reset' || $specialButton === 'start') {
|
||||
if ($specialButton === 'reset' || $specialButton === 'start' || $specialButton === 'program_picker') {
|
||||
$ids[] = $specialButton;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -369,6 +369,7 @@ class order_bookings_o extends db
|
||||
return;
|
||||
}
|
||||
|
||||
$this->requireLinkedOrderMatchesBooking($order);
|
||||
$normalizedSafetySeal = orders_o::normalizeSafetySealValue($safety_seal);
|
||||
if ($normalizedSafetySeal !== null) {
|
||||
$order->setSafetySealValue($normalizedSafetySeal);
|
||||
@@ -414,11 +415,16 @@ class order_bookings_o extends db
|
||||
continue;
|
||||
}
|
||||
$orderItems = new order_items_o();
|
||||
$itemNotes = isset($item['notes']) && trim((string)$item['notes']) !== ''
|
||||
? (string)$item['notes']
|
||||
: ((string)($this->note->value() ?? '') ?: null);
|
||||
$orderItems->addItemToOrder(
|
||||
(int)$order->id,
|
||||
(int)$item['id'],
|
||||
(int)$user_id,
|
||||
(int)$item['quantity'],
|
||||
null,
|
||||
$itemNotes,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -462,14 +468,34 @@ class order_bookings_o extends db
|
||||
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
|
||||
*/
|
||||
protected function attachWashCertificate(int $user_id, ?string $safety_seal = null): void
|
||||
{
|
||||
self::requireSelected();
|
||||
$order = $this->getOrder();
|
||||
$this->requireLinkedOrderMatchesBooking($order);
|
||||
|
||||
// Check if the order already has a wash certificate attached
|
||||
if ($this->getOrder()->hasWashCertificateAttached()) {
|
||||
if ($order->hasWashCertificateAttached()) {
|
||||
return;
|
||||
}
|
||||
// Get the operator name
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user