Compare commits

..
180 changed files with 5032 additions and 142349 deletions
+1 -7
View File
@@ -1,7 +1 @@
/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
/docker-compose.yml
+2 -2
View File
@@ -53,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=strict
EDGE_BROKER_SHARED_SECRET=
EDGE_AUTH_MODE=manager
EDGE_BROKER_SHARED_SECRET=truckwash-edge-dev
# Redis credentials
REDIS_CONFIG_HOST=redis
+42
View File
@@ -0,0 +1,42 @@
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
+6 -25
View File
@@ -9,44 +9,25 @@ on:
jobs:
qodana:
# CI runs on the repository's self-hosted runner pool.
# Run on our self-hosted runner to avoid GitHub-hosted Actions budget limits.
runs-on: [self-hosted, Linux, X64, default]
permissions:
contents: read
pull-requests: read
checks: read
contents: write
pull-requests: write
checks: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@v4
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'
if: ${{ steps.qodana-token.outputs.present == 'true' }}
uses: JetBrains/qodana-action@v2026.1
uses: JetBrains/qodana-action@v2025.3
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."
+1 -1
View File
@@ -9,7 +9,7 @@ on:
jobs:
assign-task:
runs-on: [self-hosted, Linux, X64, default]
runs-on: ubuntu-latest
permissions:
issues: write
steps:
+1 -52
View File
@@ -144,7 +144,6 @@ 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
@@ -152,7 +151,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 php2 php3 php4 php5 caddy
run: docker compose -f docker-compose.yml -f .github/docker-compose.ci.yml up -d traefik redis mysql-debug edge-broker php1 caddy
- name: Sync PHP app checkout
run: >
@@ -241,7 +240,6 @@ 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 \
@@ -255,52 +253,3 @@ 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\":[]}")"
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 }}
-2
View File
@@ -10,7 +10,5 @@
/.idea/
.env
/services/caddy/logs*
.env.old
/.tmp/
/.env.staging
/services/nginx/app/storage/replication-bootstrap.json
-1
View File
@@ -51,7 +51,6 @@ 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; \
BIN
View File
Binary file not shown.
+4 -5
View File
@@ -52,9 +52,9 @@ services:
dockerfile: services/edge-broker/Dockerfile
container_name: edge-broker
environment:
EDGE_AUTH_MODE: ${EDGE_AUTH_MODE:-strict}
EDGE_AUTH_MODE: ${EDGE_AUTH_MODE:-manager}
EDGE_MANAGER_URL: ${EDGE_MANAGER_URL:-http://caddy}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
labels:
- "traefik.enable=true"
- "traefik.http.routers.edge-broker-api.rule=Host(`api.example.com`) && PathPrefix(`/edge-broker`)"
@@ -71,7 +71,6 @@ 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
@@ -114,7 +113,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:?set EDGE_BROKER_SHARED_SECRET in .env}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
@@ -135,7 +134,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:?set EDGE_BROKER_SHARED_SECRET in .env}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
+10 -14
View File
@@ -3,8 +3,6 @@ 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"
@@ -103,10 +101,8 @@ 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:?CONFIG_DB_DEBUG_PASSWORD is required for mysql-debug}
MYSQL_ROOT_PASSWORD: ${CONFIG_DB_DEBUG_PASSWORD:-debug_root_password}
MYSQL_DATABASE: ${CONFIG_DB_DEBUG_DATABASE:-nnks_db_debug}
ports:
- "3307:3306"
@@ -125,9 +121,9 @@ services:
dockerfile: services/edge-broker/Dockerfile
container_name: edge-broker
environment:
EDGE_AUTH_MODE: ${EDGE_AUTH_MODE:-strict}
EDGE_AUTH_MODE: ${EDGE_AUTH_MODE:-manager}
EDGE_MANAGER_URL: ${EDGE_MANAGER_URL:-http://caddy}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:?set EDGE_BROKER_SHARED_SECRET in .env}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
labels:
- "traefik.enable=true"
- "traefik.http.routers.edge-broker-api.rule=Host(`api.truckwash.dk`) && PathPrefix(`/edge-broker`)"
@@ -311,7 +307,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:?set EDGE_BROKER_SHARED_SECRET in .env}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
@@ -331,7 +327,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:?set EDGE_BROKER_SHARED_SECRET in .env}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
@@ -351,7 +347,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:?set EDGE_BROKER_SHARED_SECRET in .env}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
@@ -371,7 +367,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:?set EDGE_BROKER_SHARED_SECRET in .env}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
@@ -391,7 +387,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:?set EDGE_BROKER_SHARED_SECRET in .env}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
@@ -411,7 +407,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:?set EDGE_BROKER_SHARED_SECRET in .env}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/staging:/var/www/html
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
@@ -431,7 +427,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:?set EDGE_BROKER_SHARED_SECRET in .env}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
+2 -76
View File
@@ -5874,32 +5874,6 @@ paths:
'200':
description: Success
/order-bookings/booking-confirmation/resend:
post:
tags:
- Bookings
summary: Resend order booking confirmation
description: Resends the customer booking confirmation email for an order booking. Requires `resend_booking_confirmations` and access to the booking's department.
operationId: resendOrderBookingConfirmation
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [id]
properties:
id: {type: integer}
responses:
'200':
description: Booking confirmation resent successfully
content:
application/json:
schema: {}
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/order-bookings/complete:
post:
tags:
@@ -7731,15 +7705,7 @@ paths:
description: Worker status retrieved successfully
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
api_commit_sha:
type: string
description: Running API commit SHA, or unknown when unavailable.
schema: {}
/worker/debug:
get:
@@ -8869,47 +8835,6 @@ paths:
'403':
$ref: '#/components/responses/Forbidden'
/modules/self-serve/lane/wash/my-active-wash:
get:
tags:
- Modules
summary: Get the authenticated customer's active self-serve wash
description: |
Returns the latest active self-serve wash for the authenticated customer,
without requiring the frontend to know or poll a lane id.
operationId: getMyActiveSelfServeWash
responses:
'200':
description: Active self-serve wash details resolved
content:
application/json:
schema:
type: object
properties:
lane_id:
type: integer
status:
type: string
in_progress:
type: boolean
elapsed_minutes:
type: integer
session:
type: object
nullable: true
customer:
type: object
nullable: true
vehicle:
type: object
nullable: true
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/modules/self-serve/sessions:
get:
tags:
@@ -18755,3 +18680,4 @@ components:
data:
$ref: '#/components/schemas/DepartmentDailyReportOutsideHoursTrendPayload'
-132545
View File
File diff suppressed because one or more lines are too long
-102
View File
@@ -1,102 +0,0 @@
#!/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"
+2 -8
View File
@@ -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", "php2", "php3", "php4", "php5", "caddy"];
const COMPOSE_SERVICES = ["traefik", "redis", "mysql-debug", "edge-broker", "php1", "caddy"];
function composeArgs(projectName, args) {
return ["compose", "-p", projectName, ...args];
@@ -514,10 +514,6 @@ 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
@@ -574,9 +570,7 @@ async function main() {
let runnerNetworkAttached = false;
try {
if (!shouldSkipComposeUp()) {
await ensureComposeServices(rootDir, composeProject);
}
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`);
+1 -18
View File
@@ -51,23 +51,6 @@ 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
}
cleanup() {
status="$?"
collect_logs "$status"
@@ -87,7 +70,7 @@ cleanup() {
}
trap cleanup EXIT INT TERM
retry_command "${PHP_CI_DOCKER_RETRIES:-3}" docker compose $compose_files up -d redis mysql-debug php1
docker compose $compose_files up -d redis mysql-debug php1
docker compose $compose_files exec -T php1 sh -lc '
set -eu
+3 -15
View File
@@ -25,16 +25,6 @@ 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();
@@ -76,7 +66,7 @@ export function resolveComposeProjectName(rootDir, env = process.env) {
return explicit;
}
return pathForInputs(rootDir).basename(rootDir);
return path.basename(rootDir);
}
export function resolveComposeNetworkName(rootDir, env = process.env) {
@@ -84,13 +74,11 @@ export function resolveComposeNetworkName(rootDir, env = process.env) {
}
export function resolveConfigDirectory(rootDir, explicitDir = null) {
const pathModule = pathForInputs(rootDir, explicitDir);
if (explicitDir) {
return pathModule.resolve(rootDir, explicitDir);
return path.resolve(rootDir, explicitDir);
}
return pathModule.join(rootDir, ".tmp", "test-gateway");
return path.join(rootDir, ".tmp", "test-gateway");
}
export function shouldClaimGateway(existingConfig = {}, installToken = "") {
-5
View File
@@ -10,11 +10,6 @@
# 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
-5
View File
@@ -10,11 +10,6 @@
# 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
+2 -32
View File
@@ -18,7 +18,6 @@ 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);
@@ -152,10 +151,6 @@ function buildTransportHeartbeatState(brokerState = {}) {
};
}
function isShellAccessEnabled(config = {}) {
return config.enableShellAccess === true;
}
function normalizeBrokerBaseUrl(value) {
const trimmed = String(value || "").trim().replace(/\/+$/, "");
if (trimmed === "") {
@@ -483,7 +478,7 @@ function resolveRelayToggleAfterSeconds(payload = {}) {
return null;
}
return Math.min(Math.floor(configured), MAX_RELAY_TOGGLE_AFTER_SECONDS);
return Math.floor(configured);
}
async function fetchJson(url, fetchImpl = fetch, options = {}) {
@@ -759,15 +754,6 @@ 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}`);
@@ -775,7 +761,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 (normalizedExpectedSha256 !== sha256.toLowerCase()) {
if (expectedSha256 && String(expectedSha256).toLowerCase() !== sha256.toLowerCase()) {
throw new Error(`${label} checksum mismatch`);
}
@@ -1799,10 +1785,6 @@ 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") {
@@ -1937,30 +1919,18 @@ 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 {
+2 -132
View File
@@ -1,7 +1,6 @@
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";
@@ -18,7 +17,6 @@ import {
getRelayStatus,
loadConfig,
parseCliArgs,
processPolledShellAction,
processPolledCommand,
runCli,
runUpdate,
@@ -41,10 +39,6 @@ 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;
@@ -276,31 +270,6 @@ 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 () => {
@@ -325,23 +294,19 @@ 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(agentBody);
return makeFetchResponse("// new agent\n");
}
if (String(url).endsWith("/package.json")) {
return makeFetchResponse(packageBody);
return makeFetchResponse(JSON.stringify({ name: "new-edge-agent" }, null, 2));
}
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",
@@ -369,45 +334,6 @@ 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-"));
@@ -744,7 +670,6 @@ test("startAgent reports API polling metadata, executes polled commands, and upl
commandPollRetryDelayMs: 5,
shellActionPollTimeoutSeconds: 0,
shellActionPollRetryDelayMs: 5,
enableShellAccess: true,
}));
const heartbeats = [];
@@ -1003,61 +928,6 @@ 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");
+18 -40
View File
@@ -4,7 +4,6 @@ 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) => {
@@ -56,33 +55,7 @@ function resolveAuthMode(options = {}, managerUrl = "") {
if (process.env.EDGE_AUTH_MODE) {
return process.env.EDGE_AUTH_MODE;
}
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;
return "manager";
}
function parseScopes(value) {
@@ -177,7 +150,7 @@ function rejectUpgrade(socket, statusCode, errorCode, message, details = {}) {
}
export function createBrokerServer(options = {}) {
const sharedSecret = resolveSharedSecret(options);
const sharedSecret = options.sharedSecret ?? process.env.EDGE_BROKER_SHARED_SECRET ?? "";
const managerUrl = resolveManagerUrl(options);
const authMode = resolveAuthMode(options, managerUrl);
const commandTimeoutMs = options.commandTimeoutMs ?? 10000;
@@ -487,19 +460,25 @@ export function createBrokerServer(options = {}) {
}
if (req.method === "POST" && url.pathname === "/api/diagnostics/shared-secret") {
if (!requireSharedSecret(req, res, sharedSecret)) {
if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) {
jsonResponse(res, 403, {
ok: false,
error: "Forbidden",
shared_secret_required: true,
});
return;
}
jsonResponse(res, 200, {
ok: true,
shared_secret_required: true,
shared_secret_required: Boolean(sharedSecret),
});
return;
}
if (req.method === "POST" && /^\/api\/gateways\/\d+\/commands$/.test(url.pathname)) {
if (!requireSharedSecret(req, res, sharedSecret)) {
if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) {
jsonResponse(res, 403, { error: "Forbidden" });
return;
}
@@ -542,7 +521,8 @@ export function createBrokerServer(options = {}) {
}
if (req.method === "POST" && /^\/api\/gateways\/\d+\/sync$/.test(url.pathname)) {
if (!requireSharedSecret(req, res, sharedSecret)) {
if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) {
jsonResponse(res, 403, { error: "Forbidden" });
return;
}
@@ -576,13 +556,12 @@ 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", "Gateway agent could not be validated.", {
rejectUpgrade(socket, status, error?.code || "agent_validation_failed", normalizeErrorMessage(error, "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) {
@@ -643,13 +622,12 @@ 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", "Shell session could not be validated.", {
rejectUpgrade(socket, Number(error?.status) === 403 ? 403 : 401, error?.code || "shell_session_invalid", normalizeErrorMessage(error, "Shell session could not be validated."), {
stage: "shell_session_validate",
});
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
ws.sessionToken = token;
ws.sessionInfo = session;
@@ -744,7 +722,7 @@ export function createBrokerServer(options = {}) {
return;
}
} catch (error) {
rejectUpgrade(socket, 500, "websocket_upgrade_failed", "WebSocket upgrade failed.");
rejectUpgrade(socket, 500, "websocket_upgrade_failed", normalizeErrorMessage(error, "WebSocket upgrade failed."));
return;
}
@@ -787,8 +765,8 @@ export function createBrokerServer(options = {}) {
let ingestError = null;
try {
ingested = await ingestTelemetry(String(ws.gatewayId), payload);
} catch {
ingestError = TELEMETRY_INGEST_ERROR_MESSAGE;
} catch (error) {
ingestError = error instanceof Error ? error.message : String(error);
}
const fallbackStatistics = {
system_metrics: payload?.metadata?.system_metrics || {},
+5 -107
View File
@@ -19,18 +19,6 @@ 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 }, () => {
@@ -71,7 +59,7 @@ async function waitFor(predicate, { timeoutMs = 1000, intervalMs = 10, descripti
throw new Error(`Timed out waiting for ${description}`);
}
test("broker defaults to strict auth and fails closed when manager URL is missing", async () => {
test("broker defaults to manager 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,
@@ -84,7 +72,7 @@ test("broker defaults to strict auth and fails closed when manager URL is missin
let broker;
try {
broker = createBrokerServer({ sharedSecret: "secret" });
assert.equal(broker.state.authMode, "strict");
assert.equal(broker.state.authMode, "manager");
assert.equal(broker.state.managerUrl, "");
const address = await broker.listen(0);
@@ -95,15 +83,13 @@ test("broker defaults to strict auth and fails closed when manager URL is missin
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, /"message":"Shell session could not be validated\."/);
assert.doesNotMatch(shellResponse, /Edge manager URL is not configured/);
assert.match(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, /"message":"Gateway agent could not be validated\."/);
assert.doesNotMatch(agentResponse, /Edge manager URL is not configured/);
assert.match(agentResponse, /Edge manager URL is not configured/);
} finally {
if (broker) {
await broker.close();
@@ -118,53 +104,6 @@ test("broker defaults to strict auth and fails closed when manager URL is missin
}
});
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);
@@ -399,34 +338,11 @@ 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({
@@ -502,17 +418,6 @@ 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",
@@ -838,16 +743,9 @@ test("broker still fans out telemetry when manager ingestion fails", async () =>
);
await waitFor(
() =>
browserMessages.some(
(message) => message.type === "gateway.telemetry" && message.error === "Telemetry ingestion failed"
),
() => browserMessages.some((message) => message.type === "gateway.telemetry" && message.error === "manager unavailable"),
{ 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
+5 -5
View File
@@ -39,7 +39,7 @@ test("traefik does not expose a dedicated public edge broker port", () => {
test("base docker compose routes edge broker traffic through traefik", () => {
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:-strict\}/);
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-manager\}/);
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:-strict\}/);
assert.match(serviceBlock, /EDGE_AUTH_MODE:\s*\$\{EDGE_AUTH_MODE:-manager\}/);
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("compose config does not provide insecure broker secret defaults", () => {
test("php services receive broker websocket environment 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:\?set EDGE_BROKER_SHARED_SECRET in \.env\}/);
assert.match(composeSource, /EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev\}/);
}
});
@@ -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:\?set EDGE_BROKER_SHARED_SECRET in \.env\}/);
assert.match(serviceBlock, /EDGE_BROKER_SHARED_SECRET:\s*\$\{EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev\}/);
}
});
File diff suppressed because one or more lines are too long
@@ -12204,251 +12204,3 @@
[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
+12 -15
View File
@@ -6,7 +6,6 @@ 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;
@@ -126,13 +125,9 @@ class authentication implements authentication_i
public function validate_token(string $token): bool
{
// First: try validating as a classic user auth token
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
$dbToken = (new tokens_o())->getToken($token);
if ($dbToken && $dbToken->id) {
return true;
}
// Fallback: try validating as a subuser session token
$subuser = (new subusers_o())->getSubuserBySessionToken($token);
@@ -159,17 +154,19 @@ class authentication implements authentication_i
// Strip the Bearer prefix
$rawToken = str_replace('Bearer ', '', $rawToken);
// Get the token from the database
try {
$token = (new tokens_o())->getToken($rawToken);
} catch (Exception) {
return false;
}
$token = (new tokens_o())->getToken($rawToken);
// Check if the token exists
if (!$token->id) {
return false;
}
if ($token->type->value() !== 'AUTH_TOKEN') {
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);
}
// Get the user from the database
$user = (new users_o())->getUserById($token->user_id->value());
+3 -266
View File
@@ -1371,129 +1371,6 @@ 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'));
@@ -2049,14 +1926,13 @@ class coolify_manager
], $actorUserId);
}
$sourceCommitSha = trim((string)($sourceTarget['latest_deployment_commit_sha'] ?? ''));
$deploymentInput = [
$deployment = $releaseManager->startDeployment([
'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' => $sourceCommitSha === '' ? 'latest' : 'specific',
'commit_mode' => 'latest',
'version_label' => $this->gatewayRouteProvisionVersionLabel($sourceTarget),
'deployed_url' => $sourcePublicUrl,
'metadata' => [
@@ -2066,11 +1942,7 @@ class coolify_manager
'server_uuid' => $serverUuid,
'app' => $app,
],
];
if ($sourceCommitSha !== '') {
$deploymentInput['commit_sha'] = $sourceCommitSha;
}
$deployment = $releaseManager->startDeployment($deploymentInput, $actorUserId);
], $actorUserId);
if ((string)($deployment['status'] ?? '') !== 'deployed') {
$errors[] = array_replace($action, [
@@ -3389,141 +3261,6 @@ 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,12 +154,6 @@ 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);
@@ -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, $created_by);
$active_job = $this->findActiveJobByTarget($transfer_type, $payload);
if ($active_job !== null) {
$target_label = $this->buildTargetLabel($transfer_type, $payload);
$this->logQueueEvent(
@@ -135,89 +135,6 @@ 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;
@@ -262,7 +179,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 q.created_by = $user_id
WHERE 1 = 1
$transfer_condition
AND (
q.status IN ('" . self::STATUS_QUEUED . "', '" . self::STATUS_PROCESSING . "')
@@ -297,7 +214,7 @@ class economic_transfer_queue
throw new Exception('Queue job and user are required');
}
$job = $this->getJobByIdForUser($job_id, $user_id);
$job = $this->getJobById($job_id);
if ($job === null) {
throw new Exception('Queue job not found');
}
@@ -355,8 +272,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 q.created_by = $user_id
AND q.status IN ('" . self::STATUS_COMPLETED . "', '" . self::STATUS_FAILED . "')
WHERE 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()";
@@ -368,24 +284,10 @@ 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;
$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);
$existing_job = $this->getJobById($job_id);
if ($existing_job === null) {
throw new Exception('Queue job not found');
}
@@ -396,26 +298,19 @@ class economic_transfer_queue
throw new Exception('Queue job reached max retry attempts');
}
$sql = "UPDATE economic_transfer_queue_jobs
$stmt = $db->prepare(
"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 = ?";
if ($created_by !== null) {
$sql .= " AND created_by = ?";
}
$stmt = $db->prepare($sql);
WHERE id = ? AND status = ?"
);
if (!$stmt) {
throw new Exception('Failed to prepare retry statement');
}
$queued = self::STATUS_QUEUED;
$failed = self::STATUS_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->bind_param('sis', $queued, $job_id, $failed);
$stmt->execute();
$affected = $stmt->affected_rows;
$stmt->close();
@@ -426,9 +321,7 @@ class economic_transfer_queue
$this->clearDismissalsForJob($job_id);
$job = $created_by === null
? $this->getJobById($job_id)
: $this->getJobByIdForUser($job_id, $created_by);
$job = $this->getJobById($job_id);
if ($job === null) {
throw new Exception('Retry updated job could not be loaded');
}
@@ -817,31 +710,28 @@ class economic_transfer_queue
return $this->rejectPayload($created_by, $field_name . ' must be a boolean');
}
private function findActiveJobByTarget(string $transfer_type, array $payload, int $created_by): ?array
private function findActiveJobByTarget(string $transfer_type, array $payload): ?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),
$created_by
(int)($payload['order_id'] ?? 0)
),
self::TYPE_COLLECTED_INVOICE_EXPORT => $this->findActiveJobByJsonNumericTarget(
$transfer_type,
'$.collected_invoice_id',
(int)($payload['collected_invoice_id'] ?? 0),
$created_by
(int)($payload['collected_invoice_id'] ?? 0)
),
default => null,
};
}
private function findActiveJobByJsonNumericTarget(string $transfer_type, string $json_path, int $target_value, int $created_by): ?array
private function findActiveJobByJsonNumericTarget(string $transfer_type, string $json_path, int $target_value): ?array
{
global $db;
$created_by = max(0, $created_by);
if ($target_value < 1 || $created_by < 1) {
if ($target_value < 1) {
return null;
}
@@ -851,7 +741,6 @@ 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"
);
@@ -861,7 +750,7 @@ class economic_transfer_queue
$queued = self::STATUS_QUEUED;
$processing = self::STATUS_PROCESSING;
$stmt->bind_param('sssii', $transfer_type, $queued, $processing, $target_value, $created_by);
$stmt->bind_param('sssi', $transfer_type, $queued, $processing, $target_value);
if (!$stmt->execute()) {
$stmt->close();
return null;
@@ -1,150 +0,0 @@
<?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;
}
}
@@ -320,16 +320,6 @@ 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;
@@ -347,7 +337,10 @@ class invoice_period_flag_service
}
}
return $flags;
try {
(new redis())->cache_invoice_period_manual_flags($flags);
} catch (Throwable) {
}
}
private function formatStoredFlag(array $row): array
@@ -395,11 +388,15 @@ class invoice_period_flag_service
}
if (!is_array($flags)) {
// Cache miss — read from the database and refresh Redis without hiding active flags.
$flags = $this->fetchActiveManualFlagsFromDb();
// Cache miss — warm on demand and re-fetch
$this->warmManualFlagsCache();
try {
(new redis())->cache_invoice_period_manual_flags($flags);
$flags = (new redis())->get_invoice_period_manual_flags();
} catch (Throwable) {
return [];
}
if (!is_array($flags)) {
return [];
}
}
@@ -528,12 +525,16 @@ class invoice_period_flag_service
try {
$flags = (new redis())->get_invoice_period_automatic_flags($dateFrom, $dateTo);
} catch (Throwable) {
$flags = null;
return [];
}
if (!is_array($flags)) {
$flags = $this->calculateAutomaticFlagsForPeriod($dateFrom, $dateTo);
$this->cacheAutomaticFlagsForPeriod($dateFrom, $dateTo, $flags);
// Cache miss — enqueue for warming on the next cron run
try {
(new redis())->enqueue_invoice_period_warming($dateFrom, $dateTo);
} catch (Throwable) {
}
return [];
}
if ($onlyCustomerNumbers === null) {
@@ -548,31 +549,17 @@ 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);
return array_merge(
$flags = 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) {
@@ -616,19 +603,14 @@ 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) {
$rows = null;
return [];
}
if (!is_array($rows)) {
$rows = $this->fetchOrderItemRowsFromDb($dateFrom, $dateTo);
try {
(new redis())->cache_invoice_period_order_item_rows($dateFrom, $dateTo, $rows);
} catch (Throwable) {
}
return [];
}
$this->seedOrderItemsPreviewCacheFromRows($rows);
@@ -645,7 +627,6 @@ 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);
@@ -653,24 +634,6 @@ 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;
@@ -1764,7 +1727,18 @@ class invoice_period_flag_service
if ($currentVehicleType === '' || $expectedVehicleType === '') {
return false;
}
return $currentVehicleType === $expectedVehicleType;
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) === [];
}
private function normalizePrimaryVehicleProductName(string $productName): string
+4 -35
View File
@@ -340,49 +340,18 @@ 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($departmentId, $customerId, $plates, $search),
...$this->fetchVehicleRows($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 $departmentId, ?int $customerId, array $plates, string $search): array
private function fetchVehicleRows(?int $customerId, array $plates, string $search): array
{
$contextWhere = [];
$params = [];
@@ -171,10 +171,6 @@ 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,
@@ -194,41 +190,6 @@ 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,9 +58,6 @@ 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;
}
@@ -68,9 +65,6 @@ 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
+2 -18
View File
@@ -392,19 +392,7 @@ class redis implements redis_i
private function invoicePeriodCacheKey(string $prefix, string $dateFrom, string $dateTo): string
{
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);
return $prefix . ':' . $dateFrom . ':' . $dateTo;
}
private function workfeedEmployeeNameCacheKey(string $employeeId): string
@@ -524,11 +512,7 @@ class redis implements redis_i
*/
public function enqueue_invoice_period_warming(string $dateFrom, string $dateTo): self
{
$this->get_client()->sadd('invoice_period_warming_queue', [
$this->normalizeInvoicePeriodCacheDate($dateFrom, true)
. '|'
. $this->normalizeInvoicePeriodCacheDate($dateTo, false),
]);
$this->get_client()->sadd('invoice_period_warming_queue', [$dateFrom . '|' . $dateTo]);
return $this;
}
File diff suppressed because it is too large Load Diff
@@ -118,35 +118,6 @@ 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,
@@ -437,7 +408,6 @@ class release_manager_schema_bootstrap
'release_versions',
'release_channel_versions',
'release_assignments',
'release_auto_sync_events',
'release_service_sets',
'release_deployments',
'release_bundles',
@@ -139,28 +139,6 @@ 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) {
@@ -33,7 +33,7 @@ class shelly_relay_inventory
* @return array<int,array<string,mixed>>
* @throws Exception
*/
public function listRelayOptions(bool $include_sensitive_network_details = false): array
public function listRelayOptions(): array
{
$devices_status = $this->fetchOwnedDevicesStatus();
$device_catalog = $this->fetchOwnedDeviceCatalog();
@@ -49,8 +49,7 @@ class shelly_relay_inventory
$option = $this->buildRelayOption(
$normalized_device,
is_array($catalog_entry) ? $catalog_entry : null,
$include_sensitive_network_details
is_array($catalog_entry) ? $catalog_entry : null
);
if ($option === null) {
continue;
@@ -161,11 +160,7 @@ class shelly_relay_inventory
* @param array<string,mixed> $device
* @return array<string,mixed>|null
*/
private function buildRelayOption(
array $device,
?array $catalog_entry = null,
bool $include_sensitive_network_details = false
): ?array
private function buildRelayOption(array $device, ?array $catalog_entry = null): ?array
{
if ($device === [] || !$this->isRelayCapableDevice($device)) {
return null;
@@ -208,8 +203,9 @@ class shelly_relay_inventory
$online = $this->normalizeBoolean($catalog_entry['cloud_online'] ?? null);
}
$status_color = $this->extractStatusColor($online);
$local_ip = $this->extractLocalIp($device, $catalog_entry);
$option = [
return [
'id' => $device_id,
'name' => $this->buildRelayLabel(
$device_type,
@@ -227,15 +223,10 @@ 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;
}
/**
@@ -54,7 +54,6 @@ 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'] ?? []);
@@ -108,7 +107,6 @@ class system_search_service
'offset' => $offset,
'own' => $ownCustomerNumber,
'own_only' => $ownOnlyTypes,
'dept' => $allowedDepartmentIds,
'assoc' => $includeAssociations,
'dbg' => $debugIntent,
'ctx' => $this->permissionContextFingerprint($permissionsCatalogAll, $permissionsCatalogOwn, $moduleConfigVisibility),
@@ -132,8 +130,7 @@ class system_search_service
$ownCustomerNumber,
$permissionsCatalogAll,
$permissionsCatalogOwn,
$moduleConfigVisibility,
$allowedDepartmentIds
$moduleConfigVisibility
);
$intentAssociationHint = false;
@@ -183,8 +180,7 @@ class system_search_service
$ownCustomerNumber,
$permissionsCatalogAll,
$permissionsCatalogOwn,
$moduleConfigVisibility,
$allowedDepartmentIds
$moduleConfigVisibility
);
} else {
$intentMeta['status'] = 'fallback';
@@ -211,29 +207,25 @@ class system_search_service
$activeTypes,
$this->associationEntityTypes()
));
$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);
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;
}
$initialResults = $this->mergeResults($initialResults, $associated);
$item['score'] = max((int)$item['score'], 35);
}
$initialResults = $this->mergeResults($initialResults, $associated);
}
}
}
@@ -312,7 +304,6 @@ 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>>
*/
@@ -325,7 +316,6 @@ class system_search_service
array $permissionsCatalogAll,
array $permissionsCatalogOwn,
array $moduleConfigVisibility,
array $allowedDepartmentIds = [],
array $forcedCustomerNumbers = []
): array {
$results = [];
@@ -344,7 +334,6 @@ class system_search_service
$ownOnly,
$ownCustomerNumber,
$moduleConfigVisibility,
$allowedDepartmentIds,
$forcedCustomerNumbers
);
if (empty($rows)) {
@@ -357,7 +346,6 @@ class system_search_service
$permissionsCatalogAll,
$permissionsCatalogOwn,
$moduleConfigVisibility,
$allowedDepartmentIds,
$forcedCustomerNumbers
);
}
@@ -371,7 +359,6 @@ class system_search_service
$permissionsCatalogAll,
$permissionsCatalogOwn,
$moduleConfigVisibility,
$allowedDepartmentIds,
$forcedCustomerNumbers
);
}
@@ -385,7 +372,6 @@ 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>>
*/
@@ -398,7 +384,6 @@ class system_search_service
array $permissionsCatalogAll,
array $permissionsCatalogOwn,
array $moduleConfigVisibility,
array $allowedDepartmentIds,
array $forcedCustomerNumbers
): array {
if ($this->isGenericEntityType($entityType)) {
@@ -408,7 +393,6 @@ class system_search_service
$entityBoost,
$ownOnly,
$ownCustomerNumber,
$allowedDepartmentIds,
$forcedCustomerNumbers
);
}
@@ -455,24 +439,9 @@ 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>>
*/
@@ -483,7 +452,6 @@ class system_search_service
bool $ownOnly,
?int $ownCustomerNumber,
array $moduleConfigVisibility,
array $allowedDepartmentIds,
array $forcedCustomerNumbers
): array {
global $db;
@@ -505,9 +473,6 @@ 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 = [];
@@ -1523,7 +1488,6 @@ 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>>
*/
@@ -1533,7 +1497,6 @@ class system_search_service
int $entityBoost,
bool $ownOnly,
?int $ownCustomerNumber,
array $allowedDepartmentIds = [],
array $forcedCustomerNumbers = []
): array {
if (empty($terms)) {
@@ -1639,9 +1602,7 @@ class system_search_service
$customerNumbers,
$customerField,
$customerFieldMode,
$fixedConditions,
$allowedDepartmentIds,
$departmentField
$fixedConditions
);
$this->primeCustomerContexts(array_values(array_unique(array_filter(
@@ -1837,8 +1798,6 @@ 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(
@@ -1849,9 +1808,7 @@ class system_search_service
array $customerNumbers = [],
?string $customerField = null,
string $customerFieldMode = 'default',
array $fixedConditions = [],
array $departmentIds = [],
?string $departmentField = null
array $fixedConditions = []
): array {
global $db;
@@ -1887,10 +1844,6 @@ 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);
+11 -6
View File
@@ -6,7 +6,6 @@ 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
@@ -85,7 +84,7 @@ class workfeed implements workfeed_i
$companyId = $this->requireConfiguredCompanyId();
$url = $this->buildUrl(
workfeed_api_url_c::normalizeApiUrlForValidation((string)$this->config->api_url->getVariableValue()),
$this->config->api_url->getVariableValue(),
'/companies/' . rawurlencode($companyId) . '/' . ltrim($path, '/'),
$query
);
@@ -183,10 +182,7 @@ class workfeed implements workfeed_i
private function requireConfiguredApiUrl(): void
{
$url = trim((string)$this->config->api_url->getVariableValue());
if ($url === ''
|| filter_var(workfeed_api_url_c::normalizeApiUrlForValidation($url), FILTER_VALIDATE_URL) === false
|| !workfeed_api_url_c::isTrustedApiUrl($url)
) {
if ($url === '' || filter_var($this->normalizeUrlForValidation($url), FILTER_VALIDATE_URL) === false) {
throw new Exception('Invalid Workfeed API URL configured.');
}
}
@@ -214,6 +210,15 @@ 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
*/
+15 -16
View File
@@ -5,21 +5,6 @@ $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;
@@ -55,6 +40,20 @@ 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;
@@ -119,4 +118,4 @@ if (!$isPDF) {
// Delete the file from /tmp after sending it
unlink($file_path);
exit;
}
}
+9 -29
View File
@@ -61,17 +61,6 @@ 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)
@@ -84,13 +73,11 @@ spl_autoload_register(function (string $class): void {
try {
$cached = redis->get($cache_key);
if (is_string($cached) && $cached !== '' && is_file($cached)) {
if ($isPathInside($cached, $wdReal)) {
require_once $cached;
if ($is_loaded($class)) {
return;
}
require_once $cached;
if ($is_loaded($class)) {
return;
}
// Stale, invalid, or unsafe class mapping in cache; continue with normal lookup.
// Stale 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.
@@ -133,18 +120,6 @@ 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);
@@ -262,6 +237,11 @@ 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';
@@ -5,7 +5,6 @@ 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.
@@ -50,4 +49,4 @@ class attachment_content
$this->relation = $relation;
return $this;
}
}
}
@@ -1,25 +0,0 @@
<?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'
);
}
}
@@ -1,25 +0,0 @@
<?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'
);
}
}
@@ -1,25 +0,0 @@
<?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'
);
}
}
@@ -1,25 +0,0 @@
<?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'
);
}
}
@@ -1,25 +0,0 @@
<?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,
''
);
}
}
@@ -1,38 +0,0 @@
<?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);
}
}
@@ -3426,7 +3426,7 @@ BASH;
}
try {
$this->shellyRelayOptionsCache = (new shelly_relay_inventory())->listRelayOptions(true);
$this->shellyRelayOptionsCache = (new shelly_relay_inventory())->listRelayOptions();
} catch (\Throwable) {
$this->shellyRelayOptionsCache = [];
}
@@ -4021,7 +4021,7 @@ BASH;
{
$configured = $this->configuredBrokerSharedSecret();
if ($configured === '') {
return false;
return true;
}
return $secret !== null && hash_equals($configured, trim($secret));
@@ -4036,8 +4036,12 @@ BASH;
$target = strtolower(trim((string)($options['target'] ?? 'all')));
$target = in_array($target, ['internal', 'public', 'secret', 'all'], true) ? $target : 'all';
$internalUrl = $this->normalizeBrokerDiagnosticBaseUrl($this->configuredBrokerInternalUrl());
$publicConfigured = $this->configuredPublicBrokerUrl();
$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();
$publicUrl = $this->normalizeBrokerDiagnosticBaseUrl(
$publicConfigured !== '' ? $publicConfigured : $this->deriveBrokerPublicUrl()
);
@@ -4129,7 +4133,7 @@ BASH;
$health = $this->brokerHttpProbe($baseUrl['url'] . '/api/health');
if (($health['status_code'] ?? null) === 200 && !empty($health['json']['ok'])) {
return array_merge($this->redactBrokerDiagnosticProbe($health), [
return array_merge($health, [
'ok' => true,
'status' => 'connected',
'url' => $baseUrl['url'],
@@ -4138,7 +4142,7 @@ BASH;
}
if (($health['status_code'] ?? null) === 404 && $this->isBrokerNotFoundProbe($health)) {
return array_merge($this->redactBrokerDiagnosticProbe($health), [
return array_merge($health, [
'ok' => true,
'status' => 'connected_legacy',
'url' => $baseUrl['url'],
@@ -4147,7 +4151,7 @@ BASH;
}
if (($health['status_code'] ?? null) !== null) {
return array_merge($this->redactBrokerDiagnosticProbe($health), [
return array_merge($health, [
'ok' => false,
'status' => 'unexpected_response',
'url' => $baseUrl['url'],
@@ -4155,7 +4159,7 @@ BASH;
]);
}
return array_merge($this->redactBrokerDiagnosticProbe($health), [
return array_merge($health, [
'ok' => false,
'status' => 'unreachable',
'url' => $baseUrl['url'],
@@ -4183,7 +4187,7 @@ BASH;
if (($diagnostic['status_code'] ?? null) === 200 && !empty($diagnostic['json']['ok'])) {
$required = (bool)($diagnostic['json']['shared_secret_required'] ?? false);
return array_merge($this->redactBrokerDiagnosticProbe($diagnostic), [
return array_merge($diagnostic, [
'ok' => true,
'status' => $required ? 'validated' : 'not_required',
'url' => $baseUrl['url'],
@@ -4194,7 +4198,7 @@ BASH;
}
if (($diagnostic['status_code'] ?? null) === 403) {
return array_merge($this->redactBrokerDiagnosticProbe($diagnostic), [
return array_merge($diagnostic, [
'ok' => false,
'status' => 'secret_rejected',
'url' => $baseUrl['url'],
@@ -4207,7 +4211,7 @@ BASH;
}
if (($diagnostic['status_code'] ?? null) !== null) {
return array_merge($this->redactBrokerDiagnosticProbe($diagnostic), [
return array_merge($diagnostic, [
'ok' => false,
'status' => 'unexpected_response',
'url' => $baseUrl['url'],
@@ -4215,7 +4219,7 @@ BASH;
]);
}
return array_merge($this->redactBrokerDiagnosticProbe($diagnostic), [
return array_merge($diagnostic, [
'ok' => false,
'status' => 'unreachable',
'url' => $baseUrl['url'],
@@ -4238,7 +4242,7 @@ BASH;
);
if (($legacy['status_code'] ?? null) === 200 && !empty($legacy['json']['ok'])) {
return array_merge($this->redactBrokerDiagnosticProbe($legacy), [
return array_merge($legacy, [
'ok' => true,
'status' => 'validated_legacy',
'url' => $baseUrl['url'],
@@ -4247,7 +4251,7 @@ BASH;
}
if (($legacy['status_code'] ?? null) === 403) {
return array_merge($this->redactBrokerDiagnosticProbe($legacy), [
return array_merge($legacy, [
'ok' => false,
'status' => 'secret_rejected',
'url' => $baseUrl['url'],
@@ -4255,7 +4259,7 @@ BASH;
]);
}
return array_merge($this->redactBrokerDiagnosticProbe($legacy), [
return array_merge($legacy, [
'ok' => false,
'status' => ($legacy['status_code'] ?? null) === null ? 'unreachable' : 'unexpected_response',
'url' => $baseUrl['url'],
@@ -4263,17 +4267,6 @@ 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>
@@ -4352,7 +4345,7 @@ BASH;
'elapsed_ms' => $elapsedMs,
'error' => null,
'json' => is_array($decoded) ? $decoded : null,
'body_excerpt' => null,
'body_excerpt' => self::trimInstallSessionText($body, 512),
];
}
@@ -41,6 +41,7 @@ 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,15 +39,7 @@ class edgeGatewayConfigRoute
}
(new logs_o())->add('edgegateway_config', 'global', 1, $user->id, 'EDGEGATEWAY_CONFIG', 'Successfully fetched edge gateway config');
$config = (new edgegateway())->config->getConfigRequest();
foreach ($config as &$entry) {
if (($entry['variable'] ?? null) === 'broker_shared_secret') {
$entry['value'] = '';
}
}
unset($entry);
$response->success($config);
$response->success((new edgegateway())->config->getConfigRequest());
}
private function handlePostConfig(): void
@@ -79,12 +71,7 @@ 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($diagnosticOptions));
$response->success((new edge_gateway_manager())->diagnoseBrokerConfiguration($payload));
}
}
@@ -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, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
// Execute the request
$response = curl_exec($ch);
// Check for errors
@@ -44,7 +44,11 @@ class limble_request implements limble_request_i
// Check if the response is successful
if ($httpCode < 200 || $httpCode >= 300) {
$slack = new \classes\slack();
$slack->send_message('Limble Request Failed with status code: ' . $httpCode, 'Limble Request Error');
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');
throw new \Exception('Request failed with status code ' . $httpCode);
}
// Check if the response is valid JSON
@@ -64,4 +68,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,7 +6,6 @@ class selfserve_lane_command_arguments
{
public ?string $license_plate = null;
public ?int $customer_number = null;
public ?int $subuser_id = null;
public bool $defer_relay_side_effects = false;
/**
@@ -26,12 +25,6 @@ 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 setDeferRelaySideEffects(bool $defer_relay_side_effects): self
{
$this->defer_relay_side_effects = $defer_relay_side_effects;
@@ -47,9 +40,6 @@ 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('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[$conditionId] ?? false) !== true)) {
if ($conditionId !== null && $conditionResults !== null && (($conditionResults[$conditionId] ?? false) !== true)) {
continue;
}
$actions[] = $action;
File diff suppressed because it is too large Load Diff
@@ -273,19 +273,28 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return;
}
$this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE);
$this->turnOffRelayIfConfigured($lane, selfserve_lane_relay::MACHINE_CLEANER);
$this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE);
$this->turnOffRelayIfConfiguredAndOn($lane, selfserve_lane_relay::MACHINE_CLEANER);
} catch (\Throwable) {
// Best effort only; session completion flow must continue.
}
}
protected function turnOffRelayIfConfigured(selfserve_lane $lane, selfserve_lane_relay $relay): void
protected function turnOffRelayIfConfiguredAndOn(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) {
@@ -714,14 +723,14 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
protected function enableMachineRelayIfAllowed(array $snapshot, selfserve_wash_sessions_o $session): void
{
$laneId = (int)$snapshot['lane']['id'];
$lane = (new selfserve())->lane($laneId);
$this->enableCleanerRelayForStartedWash($lane);
if ((bool)$session->machine_relay_enabled->value() === true) {
return;
}
$laneId = (int)$snapshot['lane']['id'];
$lane = (new selfserve())->lane($laneId);
$this->enableCleanerRelayForStartedWash($lane);
$session->markRelayEnabled();
$this->logSessionEvent((int)$session->id, selfserve_wash_event_type::MACHINE_RELAY_ENABLED, [
'lane_id' => $laneId,
@@ -2399,20 +2408,11 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
protected function taskUsesProgramPicker(array $task): bool
{
if (in_array(
return 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
@@ -2422,15 +2422,7 @@ class selfserve_wash_flow implements selfserve_wash_flow_i
return $buttons;
}
$sequence = ['program_picker'];
foreach ($buttons as $button) {
if ($button === 'program_picker' || $this->isProgramNumberButton($button)) {
continue;
}
$sequence[] = $button;
}
return $this->normalizeButtonList($sequence);
return $this->normalizeButtonList(array_merge(['program_picker'], $buttons));
}
/**
@@ -2962,7 +2954,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) === 0 || (int)($question['lane'] ?? 0) === $laneId)
&& (int)($question['lane'] ?? 0) === $laneId
&& (int)($question['product'] ?? 0) === $vehicleTypeId;
}));
}
@@ -3005,7 +2997,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) === 0 || (int)($condition['lane'] ?? 0) === $laneId)
&& (int)($condition['lane'] ?? 0) === $laneId
&& (int)($condition['product'] ?? 0) === $vehicleTypeId;
}));
}
@@ -3049,7 +3041,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) === 0 || (int)($task['lane'] ?? 0) === $laneId)
&& (int)($task['lane'] ?? 0) === $laneId
&& (int)($task['product'] ?? 0) === $vehicleTypeId;
}));
}
@@ -9,7 +9,6 @@ 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';
@@ -78,4 +77,4 @@ trait selfserve_lane_cache_t
redis->delete($this->getLaneCacheKey($laneId, $property));
return $this;
}
}
}
@@ -38,49 +38,6 @@ 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
@@ -176,7 +133,8 @@ trait selfserve_lane_command_t
if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE)) {
return;
}
if ($this->isMachineWashSelectedAndAvailableForStart()) {
$active_wash = new selfserve_wash_flow();
if ($active_wash->isMachineAllowedToStartWash($this->id)) {
try {
$this->setMachineRelayStatusHard(true);
} catch (\Throwable) {
@@ -192,57 +150,6 @@ trait selfserve_lane_command_t
}
}
/**
* Keep the program picker relay aligned with the selected wash mode at START.
* It is ON only when the active self-serve session is allowed to start machine wash.
*/
protected function setProgramPickerRelayStatusForWashStart(): void
{
if (!$this->isRelayConfigured(selfserve_lane_relay::MACHINE_PROGRAM_PICKER)) {
return;
}
try {
$shouldEnable = $this->isMachineWashSelectedAndAvailableForStart();
$this->setMachineProgramPickerRelayStatusHard($shouldEnable);
} catch (\Throwable) {
// Best effort only; wash start must continue.
}
}
protected function isMachineWashSelectedAndAvailableForStart(): bool
{
if (!$this->isMachineServiceSelectedForWashStart()) {
return false;
}
try {
return (new selfserve_wash_flow())->isMachineAllowedToStartWash((int)$this->id);
} catch (\Throwable) {
return false;
}
}
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 {
@@ -327,15 +234,27 @@ trait selfserve_lane_command_t
return;
}
// Ensure cleaner relay is enabled whenever wash starts.
$this->turnOnCleanerRelayForWashStart();
$this->setProgramPickerRelayStatusForWashStart();
// Ensure the machine relay is ON when a wash starts, when it is allowed.
$this->setMachineRelayStatusForWashStart();
}
protected function resolveSelfServeActionWashModeForStart(): string
{
if ($this->isMachineServiceSelectedForWashStart()) {
return selfserve_studio_actions::MODE_MACHINE;
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.
}
return selfserve_studio_actions::MODE_MANUAL;
@@ -420,16 +339,14 @@ trait selfserve_lane_command_t
}
/**
* Disable relays before opening exit gates in deterministic order:
* Disable relays after STOP in deterministic order:
* 1. Cleaner relay
* 2. Program picker relay
* 3. Machine relay
* 2. Machine relay
*/
protected function turnOffRelaysAfterStop(): void
{
$relays = [
selfserve_lane_relay::MACHINE_CLEANER,
selfserve_lane_relay::MACHINE_PROGRAM_PICKER,
selfserve_lane_relay::MACHINE,
];
@@ -547,7 +464,6 @@ 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
{
@@ -592,48 +508,38 @@ 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);
$start_lock_token = $this->acquireLaneStartCommandLock();
$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);
try {
// 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(),
[
'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);
// 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;
}
// 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
@@ -644,8 +550,6 @@ 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,
@@ -658,10 +562,12 @@ 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($arguments);
$this->invoice();
// 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,24 +4,15 @@ 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
{
@@ -111,7 +102,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(?selfserve_lane_command_arguments $arguments = null): bool
public function invoice(): bool
{
$this->last_invoice_order_id = null;
@@ -124,10 +115,12 @@ trait selfserve_lane_invoice_t
$included_minutes = $this->resolveIncludedMinutesForBilling();
$billable_minutes = $this->calculateBillableMinutes($elapsed_minutes, $included_minutes);
if ($billable_minutes > 0) {
$order = $this->createInvoiceOrderContext($arguments);
$this->billable_minutes_order_item = $this->addMinuteBillingLine((int)$order->id, (int)$product_id, $billable_minutes);
if ($billable_minutes <= 0) {
return true;
}
$order = $this->createInvoiceOrderContext();
$this->billable_minutes_order_item = $this->addMinuteBillingLine((int)$order->id, (int)$product_id, $billable_minutes);
return true;
}
@@ -219,12 +212,10 @@ trait selfserve_lane_invoice_t
return max(0, $elapsed_minutes - $included_minutes);
}
protected function createInvoiceOrderContext(?selfserve_lane_command_arguments $arguments = null): orders_o
protected function createInvoiceOrderContext(): orders_o
{
$billing_customer_number = $this->getCustomerNumber();
$draft_customer_number = (new economic())->getTransactionDraftCustomerNumber();
$order = (new orders_o())->add(
$billing_customer_number,
$this->getCustomerNumber(),
self::INVOICE_SYSTEM_USER_ID,
'',
'',
@@ -233,101 +224,10 @@ 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,7 +17,6 @@ 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
@@ -106,7 +105,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 min($toggle_after_seconds, self::MAX_PORT_OPEN_TOGGLE_AFTER_SECONDS);
return $toggle_after_seconds;
}
return self::DEFAULT_PORT_OPEN_TOGGLE_AFTER_SECONDS;
@@ -107,16 +107,14 @@ trait selfserve_lane_relay_controller_t
}
/**
* Set MACHINE relay status using the same guards as manual relay controls.
* Set MACHINE relay status directly.
* @param bool $on true to turn on, false to turn off
* @return bool
* @throws \Exception
*/
public function setMachineRelayStatus(bool $on): bool
{
return $on
? $this->turnOnRelay(selfserve_lane_relay::MACHINE)
: $this->turnOffRelay(selfserve_lane_relay::MACHINE);
return $this->setRelayStatus(selfserve_lane_relay::MACHINE, $on);
}
/**
@@ -87,44 +87,4 @@ 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,
];
}
}
}
@@ -20,11 +20,7 @@ if (!is_numeric($certificate_id) || $certificate_id < 1) {
return;
}
// 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;
}
// TODO: Add authentication here
// Check if the certificate exists in the /output/certificates folder
if (!file_exists("../output/certificates/wash_certificate_" . $certificate_id . ".pdf")) {
@@ -38,4 +34,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,14 +7,7 @@ use traits\module_config_variable;
class workfeed_api_url_c
{
use module_config_variable {
validateVariableValue as private validateModuleConfigVariableValue;
}
private const TRUSTED_API_HOSTS = [
'api.workfeed.io',
'europe-west1-production-eu-327a3.cloudfunctions.net',
];
use module_config_variable;
/**
* @throws Exception
@@ -27,54 +20,10 @@ class workfeed_api_url_c
'string',
true,
null,
'The base URL for the Workfeed API (trusted Workfeed endpoints only; see docs.workfeed.io)',
'The base URL for the Workfeed API (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,7 +200,6 @@ 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);
@@ -130,10 +130,6 @@ 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(),
@@ -147,76 +143,13 @@ 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' => $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,
'status' => (string)$this->getLaneStatus()->name,
// 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();
@@ -251,29 +184,6 @@ 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) {
@@ -369,7 +369,6 @@ class order_bookings_o extends db
return;
}
$this->requireLinkedOrderMatchesBooking($order);
$normalizedSafetySeal = orders_o::normalizeSafetySealValue($safety_seal);
if ($normalizedSafetySeal !== null) {
$order->setSafetySealValue($normalizedSafetySeal);
@@ -415,16 +414,11 @@ 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,
);
}
@@ -468,34 +462,14 @@ 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 ($order->hasWashCertificateAttached()) {
if ($this->getOrder()->hasWashCertificateAttached()) {
return;
}
// Get the operator name
+3 -11
View File
@@ -270,8 +270,6 @@ class orders_o extends db
public function getOrderHistoryByVehiclePlate(string $plate, int $entries = 10): array
{
global $db;
$plate = $db->escape_string($plate);
$entries = max(1, $entries);
$sql = "SELECT * FROM $this->table WHERE reg_1 = '$plate' OR reg_2 = '$plate' OR reg_3 = '$plate' ORDER BY id DESC LIMIT $entries";
$result = $db->query($sql);
return $db->fetch_all($result);
@@ -539,7 +537,6 @@ class orders_o extends db
{
global /** @var db $db */
$db;
$plate = $db->escape_string($plate);
$sql = "SELECT id FROM $this->table WHERE reg_1 = '$plate' OR reg_2 = '$plate' OR reg_3 = '$plate' AND deleted_at IS NULL ORDER BY id DESC LIMIT 5";
$result = $db->query($sql);
$orders = $db->fetch_all($result);
@@ -593,14 +590,8 @@ class orders_o extends db
public function get_vehicle_order_history(string $plate): array
{
global $db;
$stmt = $db->prepare("SELECT * FROM $this->table WHERE (reg_1 = ? OR reg_2 = ? OR reg_3 = ?) AND deleted_at IS NULL ORDER BY id DESC LIMIT 5");
if (!$stmt) {
return [];
}
$stmt->bind_param('sss', $plate, $plate, $plate);
$stmt->execute();
$result = $stmt->get_result();
$stmt->close();
$sql = "SELECT * FROM $this->table WHERE reg_1 = '$plate' OR reg_2 = '$plate' OR reg_3 = '$plate' AND deleted_at IS NULL ORDER BY id DESC LIMIT 5";
$result = $db->query($sql);
return $db->fetch_all($result);
}
@@ -2164,6 +2155,7 @@ class orders_o extends db
}
return $orders;
}
/**
* @throws Exception
*/
+1 -26
View File
@@ -11,9 +11,6 @@ class products_o extends db
{
use db_object_t;
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27;
public const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = 'Ekstraordinær pr. 10 min inkl. kemi';
/**
* The name of the product
* @var object_property
@@ -217,7 +214,7 @@ class products_o extends db
'piktogram' => $this->piktogram->value(),
'economic_product_id' => $this->economic_product_id->value(),
'apply_category_discount' => (bool)$this->apply_category_discount->value(),
'requires_note' => $this->requiresOrderItemNote(),
'requires_note' => (bool)$this->requires_note->value(),
'is_wash' => (bool)$this->is_wash->value(),
'display_in_booking_form' => (bool)$this->display_in_booking_form->value(),
'order_priority' => (int)$this->order_priority->value(),
@@ -227,28 +224,6 @@ class products_o extends db
];
}
public static function productDataRequiresOrderItemNote(array $product): bool
{
if ((bool)($product['requires_note'] ?? false)) {
return true;
}
if ((int)($product['id'] ?? 0) === self::EXTRAORDINARY_CHEMISTRY_PRODUCT_ID) {
return true;
}
return trim((string)($product['name'] ?? '')) === self::EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME;
}
public function requiresOrderItemNote(): bool
{
return self::productDataRequiresOrderItemNote([
'id' => $this->id,
'name' => (string)$this->name->value(),
'requires_note' => (bool)$this->requires_note->value(),
]);
}
/**
* Apply department pricing to a list of products
* @param array $products
+12 -42
View File
@@ -22,57 +22,28 @@ class subuser_grants_o extends db
public object_property $updated_at;
public object_property $deleted_at;
const defaultPermissions = [
'VEHICLES_LIST',
'SELFSERVE_ADD',
'BOOKINGS_LIST',
'BOOKINGS_ADD',
'BOOKINGS_EDIT',
'BOOKINGS_DELETE',
subusers_permission_node_key::VEHICLES_LIST,
subusers_permission_node_key::SELFSERVE_ADD,
subusers_permission_node_key::BOOKINGS_LIST,
subusers_permission_node_key::BOOKINGS_ADD,
subusers_permission_node_key::BOOKINGS_EDIT,
subusers_permission_node_key::BOOKINGS_DELETE,
];
public static function normalizePermissionsValue(mixed $raw): array
private static function normalizePermissionsValue(mixed $raw): array
{
if ($raw === null || $raw === '' || $raw === false || $raw === 0 || $raw === '0') {
if ($raw === null || $raw === '') {
return [];
}
if ($raw instanceof subusers_permission_node_key) {
return [$raw->name];
}
if (is_array($raw)) {
$permissions = [];
$permissionCandidates = array_is_list($raw)
? $raw
: array_keys(array_filter($raw, static fn ($enabled): bool => (bool)$enabled));
foreach ($permissionCandidates as $permission) {
if ($permission instanceof subusers_permission_node_key) {
$permission = $permission->name;
}
if (!is_string($permission)) {
continue;
}
$permission = strtoupper(trim($permission));
if ($permission !== '' && subusers_permission_node_key::tryFrom($permission) !== null) {
$permissions[] = $permission;
}
}
return array_values(array_unique($permissions));
return array_values(array_filter($raw, static fn ($permission) => is_string($permission) && trim($permission) !== ''));
}
if (is_string($raw)) {
$decoded = json_decode($raw, true);
if (json_last_error() === JSON_ERROR_NONE) {
return self::normalizePermissionsValue($decoded);
}
$permission = strtoupper(trim($raw));
if (subusers_permission_node_key::tryFrom($permission) !== null) {
return [$permission];
if (is_array($decoded)) {
return array_values(array_filter($decoded, static fn ($permission) => is_string($permission) && trim($permission) !== ''));
}
}
@@ -132,13 +103,12 @@ class subuser_grants_o extends db
public function add(int $billing_customer_number, int $subuser, bool $enabled, ?string $note, ?array $permissions = self::defaultPermissions): subuser_grants_o
{
global $db;
$permissions = self::normalizePermissionsValue($permissions);
$tmp = $this->add_object([
'billing_customer_number' => (int)$billing_customer_number,
'subuser' => (int)$subuser,
'enabled' => (bool)$enabled,
'note' => !empty($note) ? $db->escape_string($note) : null,
'permissions' => json_encode($permissions, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'permissions' => !empty($permissions) ? json_encode($permissions) : json_encode([]),
]);
$this->id = (int)$tmp;
$this->getObjectProperties();
+24 -26
View File
@@ -15,11 +15,6 @@ class subusers_o extends db
{
use db_object_t;
public const PASSWORD_MIN_LENGTH = 8;
public const PASSWORD_MAX_LENGTH = 255;
public const PASSWORD_PATTERN = '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/';
public const PASSWORD_COMPLEXITY_MESSAGE = 'Password must contain at least one uppercase letter, one lowercase letter, and one number';
public object_property $username;
public object_property $password;
public object_property $name;
@@ -67,23 +62,6 @@ class subusers_o extends db
return (bool)$this->two_factor_enabled->value();
}
/**
* @throws Exception
*/
public static function assertValidPassword(string $password): void
{
if (
strlen($password) < self::PASSWORD_MIN_LENGTH
|| strlen($password) > self::PASSWORD_MAX_LENGTH
|| !preg_match(self::PASSWORD_PATTERN, $password)
) {
throw new Exception(
'Password must be between ' . self::PASSWORD_MIN_LENGTH . ' and ' . self::PASSWORD_MAX_LENGTH
. ' characters long and contain at least one uppercase letter, one lowercase letter, and one number.'
);
}
}
/**
* @throws Exception
*/
@@ -125,9 +103,11 @@ class subusers_o extends db
{
global $db, $response;
try {
$passwordWasProvided = !empty($password);
if ($passwordWasProvided) {
self::assertValidPassword($password);
if (!empty($password)) {
// Validate the password (at least 8 characters, at least one uppercase letter, at least one lowercase letter, at least one number)
if (!preg_match('/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/', $password)) {
throw new Exception('Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, and one number.');
}
// Hash the password
$password = password_hash($password, PASSWORD_DEFAULT);
}
@@ -183,7 +163,6 @@ class subusers_o extends db
public function setPassword(string $password): self
{
self::requireSelected();
self::assertValidPassword($password);
$this->password->set((string)password_hash($password, PASSWORD_DEFAULT));
return $this;
}
@@ -320,6 +299,9 @@ class subusers_o extends db
$session_token = bin2hex(random_bytes(32));
$this->cache('session_token:' . $session_token, $this->id, 'subuser_sessions');
$this->setCachedExpiration('session_token:' . $session_token, 7 * 24 * 60 * 60, 'subuser_sessions'); // Set the session to expire after 7 days
// Add the token
$tokens_o = new tokens_o();
$tokens_o->create($this->id, $session_token, 'AUTH_TOKEN_SUBUSER');
return $session_token;
}
@@ -343,6 +325,22 @@ class subusers_o extends db
$subuser->getObjectProperties();
return $subuser;
}
// Fallback: resolve via tokens table if cache is missing/expired
try {
// tokens_o::getToken() might throw Exception if not found
$tok = (new tokens_o())->getToken($token);
if ($tok && $tok->id && $tok->type->value() === 'AUTH_TOKEN_SUBUSER') {
$resolvedId = (int)$tok->user_id->value();
// Re-cache mapping for future lookups (7 days to match session lifetime)
$this->cache($cache_key, $resolvedId, $cache_object_id);
$this->setCachedExpiration($cache_key, 7 * 24 * 60 * 60, $cache_object_id);
$subuser = (new subusers_o())->select($resolvedId);
$subuser->getObjectProperties();
return $subuser;
}
} catch (Exception $e) {
// Token not found or other error; treat as missing
}
return null;
}
+10 -210
View File
@@ -5460,7 +5460,7 @@ paths:
tags:
- Self-Serve
summary: Bulk save all-in-one self-serve studio graph changes
description: Creates, updates, deletes, connects, disconnects, reorders, and upserts self-serve answer paths by editing the schema_version 2 draft config JSON. Condition connections create expression predicates; Path Editor upserts create normal generated condition and task nodes; layout remains separate from runtime behavior.
description: Creates, updates, deletes, connects, disconnects, and reorders questions, conditions, and tasks by editing the schema_version 2 draft config JSON. Condition connections create expression predicates; layout remains separate from runtime behavior.
operationId: saveSelfserveStudioGraph
requestBody:
required: true
@@ -5617,29 +5617,6 @@ paths:
'422':
$ref: '#/components/responses/BadRequest'
/department/selfserve/studio/path-confirmations:
post:
tags:
- Self-Serve
summary: Confirm or reset a projected self-serve studio path
description: Stores confirmation for a projected terminal path using its stable path and result signatures. Projections report confirmed, unconfirmed, or stale when the resulting tasks, buttons, services, or signals change.
operationId: confirmSelfserveStudioPath
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SelfserveStudioPathConfirmationRequest'
responses:
'200':
description: Path confirmation updated
content:
application/json:
schema:
$ref: '#/components/schemas/SelfserveStudioPathConfirmation'
'422':
$ref: '#/components/responses/BadRequest'
/department/selfserve/studio/publish:
post:
tags:
@@ -6239,32 +6216,6 @@ paths:
'200':
description: Success
/order-bookings/booking-confirmation/resend:
post:
tags:
- Bookings
summary: Resend order booking confirmation
description: Resends the customer booking confirmation email for an order booking. Requires `resend_booking_confirmations` and access to the booking's department.
operationId: resendOrderBookingConfirmation
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [id]
properties:
id: {type: integer}
responses:
'200':
description: Booking confirmation resent successfully
content:
application/json:
schema: {}
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/order-bookings/complete:
post:
tags:
@@ -8096,15 +8047,7 @@ paths:
description: Worker status retrieved successfully
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
api_commit_sha:
type: string
description: Running API commit SHA, or unknown when unavailable.
schema: {}
/worker/debug:
get:
@@ -9234,47 +9177,6 @@ paths:
'403':
$ref: '#/components/responses/Forbidden'
/modules/self-serve/lane/wash/my-active-wash:
get:
tags:
- Modules
summary: Get the authenticated customer's active self-serve wash
description: |
Returns the latest active self-serve wash for the authenticated customer,
without requiring the frontend to know or poll a lane id.
operationId: getMyActiveSelfServeWash
responses:
'200':
description: Active self-serve wash details resolved
content:
application/json:
schema:
type: object
properties:
lane_id:
type: integer
status:
type: string
in_progress:
type: boolean
elapsed_minutes:
type: integer
session:
type: object
nullable: true
customer:
type: object
nullable: true
vehicle:
type: object
nullable: true
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
/modules/self-serve/sessions:
get:
tags:
@@ -9397,10 +9299,10 @@ paths:
description: |
Send a command (e.g., start, stop, reset) to a self-serve lane.
Property gate commands (`OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`) are also supported here.
Property gate commands require the matching explicit command permissions.
Operator callers require the base command permission plus the command-specific permission. Authenticated
customers with `list_own_department_selfserve_vehicle_conditions` may send `START` on enabled self-serve
lanes. Customer `STOP` requires the customer's active self-serve wash in the target department.
lanes. Customer `STOP` and property gate commands require the customer's active self-serve wash in the target
department.
operationId: sendSelfServeLaneCommand
requestBody:
required: true
@@ -17988,10 +17890,10 @@ components:
properties:
action:
type: string
enum: [create, update, delete, connect, disconnect, reorder, upsert, upsert_path]
enum: [create, update, delete, connect, disconnect, reorder]
entity:
type: string
enum: [question, condition, task, action, path]
enum: [question, condition, task]
description: Standalone rule operations are not accepted for schema_version 2 drafts.
id:
type: integer
@@ -18350,17 +18252,13 @@ components:
max_states:
type: integer
minimum: 1
maximum: 2048
default: 2048
nullable: true
description: Optional debug cap for explored states. Omitted and larger values are capped at 2048.
description: Optional debug cap. Omit for complete path projection.
path_sample_limit:
type: integer
minimum: 1
maximum: 200
default: 200
nullable: true
description: Optional cap for returned path rows. Omitted and larger values are capped at 200.
description: Optional debug cap for returned path rows. Omit to return every terminal path row.
SelfserveStudioPathOutcomesResponse:
type: object
@@ -18382,8 +18280,6 @@ components:
items: { type: integer }
max_states: { type: integer }
path_sample_count: { type: integer }
confirmations:
$ref: '#/components/schemas/SelfserveStudioPathConfirmationSummary'
outcomes:
type: array
items:
@@ -18399,15 +18295,6 @@ components:
type: boolean
progress:
$ref: '#/components/schemas/SelfserveStudioPathProgress'
confirmations:
type: object
properties:
summary:
$ref: '#/components/schemas/SelfserveStudioPathConfirmationSummary'
removed:
type: array
items:
$ref: '#/components/schemas/SelfserveStudioPathConfirmation'
SelfserveStudioPathProgress:
type: object
@@ -18493,95 +18380,6 @@ components:
node_ids:
type: array
items: { type: string }
path_signature: { type: string }
result_signature: { type: string }
confirmation_status:
type: string
enum: [unconfirmed, confirmed, stale]
confirmed_at:
type: string
nullable: true
confirmed_by:
type: integer
nullable: true
stale_reason:
type: string
nullable: true
SelfserveStudioPathConfirmationSummary:
type: object
properties:
confirmed: { type: integer }
unconfirmed: { type: integer }
stale: { type: integer }
removed: { type: integer }
total: { type: integer }
SelfserveStudioPathConfirmationRequest:
type: object
required: [department, path_signature]
properties:
department: { type: integer }
action:
type: string
enum: [confirm, reset, delete, clear]
default: confirm
path_signature: { type: string }
result_signature:
type: string
description: Required when action is confirm.
scope:
type: object
additionalProperties: true
answers:
type: array
items:
$ref: '#/components/schemas/SelfserveStudioPathAnswer'
result:
type: object
additionalProperties: true
SelfserveStudioPathConfirmation:
type: object
properties:
id:
type: integer
nullable: true
department_id: { type: integer }
lane_id:
type: integer
nullable: true
vehicle_type_id:
type: integer
nullable: true
config_version_id:
type: integer
nullable: true
config_source: { type: string }
path_signature: { type: string }
result_signature: { type: string }
confirmation_status:
type: string
enum: [unconfirmed, confirmed, stale]
answers:
type: array
items:
$ref: '#/components/schemas/SelfserveStudioPathAnswer'
result:
type: object
additionalProperties: true
scope:
type: object
additionalProperties: true
confirmed_at:
type: string
nullable: true
confirmed_by:
type: integer
nullable: true
stale_reason:
type: string
nullable: true
SelfserveStudioPathTask:
type: object
@@ -21393,3 +21191,5 @@ components:
success: { type: boolean, example: true }
data:
$ref: '#/components/schemas/DepartmentDailyReportOutsideHoursTrendPayload'
@@ -3,10 +3,9 @@ FROM ${BASE_IMAGE}
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose libcurl4-openssl-dev libsqlite3-dev; \
docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite; \
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \
rm -rf /var/lib/apt/lists/*
apt-get install -y --no-install-recommends bash ca-certificates curl docker.io docker-compose; \
rm -rf /var/lib/apt/lists/*; \
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'
COPY auto-updater.php /usr/local/bin/auto-updater.php
@@ -2,11 +2,7 @@ ARG BASE_IMAGE=php:8.2-cli-bookworm
FROM ${BASE_IMAGE}
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends libcurl4-openssl-dev libsqlite3-dev; \
docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite; \
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \
rm -rf /var/lib/apt/lists/*
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'
WORKDIR /opt/truckwash-edge-agent
@@ -2,11 +2,7 @@ ARG BASE_IMAGE=php:8.2-cli-bookworm
FROM ${BASE_IMAGE}
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends libcurl4-openssl-dev libsqlite3-dev; \
docker-php-ext-install -j"$(nproc)" curl sqlite3 pdo_sqlite; \
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'; \
rm -rf /var/lib/apt/lists/*
php -r 'foreach (["curl", "sqlite3"] as $extension) { if (!extension_loaded($extension)) { fwrite(STDERR, "Missing PHP extension: {$extension}\n"); exit(1); } }'
WORKDIR /opt/truckwash-edge-agent
@@ -61,7 +61,7 @@ final class OperationAbortException extends RuntimeException
final class HttpJsonClient
{
public function __construct(private readonly string $baseUrl, private readonly array $defaultHeaders = [])
public function __construct(private readonly string $baseUrl)
{
}
@@ -91,7 +91,7 @@ final class HttpJsonClient
private function requestJson(string $method, string $url, ?array $payload, int $timeoutSeconds): array
{
$headers = array_values(array_merge(['Accept: application/json'], $this->defaultHeaders));
$headers = ['Accept: application/json'];
if ($payload !== null) {
$headers[] = 'Content-Type: application/json';
}
@@ -1031,10 +1031,7 @@ final class TruckwashEdgeAgent
}
$this->http = new HttpJsonClient((string)$this->config->get('apiUrl'));
$this->workerHttp = new HttpJsonClient(
(string)$this->config->get('workerBaseUrl', self::DEFAULT_WORKER_BASE_URL),
$this->workerAuthorizationHeaders()
);
$this->workerHttp = new HttpJsonClient((string)$this->config->get('workerBaseUrl', self::DEFAULT_WORKER_BASE_URL));
$this->logger = new Logger($this->runtimeDir . DIRECTORY_SEPARATOR . 'agent.log');
$this->stateStore = new LocalStateStore((string)$this->config->get('stateDatabasePath', self::DEFAULT_STATE_DATABASE));
$this->statePath = $this->runtimeDir . DIRECTORY_SEPARATOR . 'current-operation.json';
@@ -2618,12 +2615,6 @@ final class TruckwashEdgeAgent
}
}
private function workerAuthorizationHeaders(): array
{
$agentToken = trim((string)$this->config->get('agentToken', ''));
return $agentToken !== '' ? ['X-Truckwash-Worker-Token: ' . $agentToken] : [];
}
private function ensureAgentInstanceId(): string
{
$configured = trim((string)$this->config->get('agentInstanceId', ''));
@@ -5,13 +5,11 @@ services:
image: ${REDIS_BASE_IMAGE:-redis:7-alpine}
container_name: truckwash-redis
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:?set REDIS_PASSWORD}"]
environment:
REDIS_PASSWORD: "${REDIS_PASSWORD:?set REDIS_PASSWORD}"
command: ["redis-server", "--appendonly", "yes"]
volumes:
- ./runtime/redis:/data
healthcheck:
test: ["CMD-SHELL", 'redis-cli -a "$$REDIS_PASSWORD" ping | grep -q PONG']
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 5s
retries: 5
@@ -21,10 +19,10 @@ services:
container_name: truckwash-mariadb
restart: unless-stopped
environment:
MARIADB_DATABASE: ${MARIADB_DATABASE:-truckwash_edge}
MARIADB_USER: ${MARIADB_USER:-truckwash_edge}
MARIADB_PASSWORD: "${MARIADB_PASSWORD:?set MARIADB_PASSWORD}"
MARIADB_ROOT_PASSWORD: "${MARIADB_ROOT_PASSWORD:?set MARIADB_ROOT_PASSWORD}"
MARIADB_DATABASE: truckwash_edge
MARIADB_USER: truckwash_edge
MARIADB_PASSWORD: truckwash_edge
MARIADB_ROOT_PASSWORD: truckwash_edge_root
volumes:
- ./runtime/mariadb:/var/lib/mysql
@@ -34,8 +32,8 @@ services:
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: "${MINIO_ROOT_USER:?set MINIO_ROOT_USER}"
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:?set MINIO_ROOT_PASSWORD}"
MINIO_ROOT_USER: truckwashminio
MINIO_ROOT_PASSWORD: truckwash_edge_storage
volumes:
- ./runtime/minio:/data
@@ -57,7 +55,6 @@ services:
minio:
condition: service_started
volumes:
- ./config.json:/config/config.json:ro
- ./runtime:/opt/truckwash-edge-agent/runtime
healthcheck:
test:
@@ -5,7 +5,6 @@ ACTION="${1:-up}"
INSTALL_DIR="${TRUCKWASH_INSTALL_DIR:-/opt/truckwash-edge-agent}"
CONFIG_PATH="$INSTALL_DIR/config.json"
COMPOSE_FILE="$INSTALL_DIR/docker-compose.gateway.yml"
ENV_FILE="$INSTALL_DIR/.env"
RUNTIME_DIR="$INSTALL_DIR/runtime"
ROLLBACK_STATUS_PATH="$RUNTIME_DIR/rollback-status.json"
STAGED_UPDATE_PATH="$RUNTIME_DIR/staged-update.json"
@@ -65,42 +64,6 @@ ensure_dirs() {
mkdir -p "$RUNTIME_DIR" "$RUNTIME_DIR/backups"
}
ensure_stack_env() {
php -r '
$path = $argv[1];
$content = is_file($path) ? (string)file_get_contents($path) : "";
$hasValue = static function (string $name) use ($content): bool {
if (!preg_match("/^" . preg_quote($name, "/") . "=(.*)$/m", $content, $matches)) {
return false;
}
return trim((string)$matches[1], " \t\"") !== "";
};
$secret = static function (int $bytes): string {
return rtrim(strtr(base64_encode(random_bytes($bytes)), "+/", "-_"), "=");
};
$generated = [];
$required = [
"REDIS_PASSWORD" => static fn(): string => $secret(32),
"MARIADB_PASSWORD" => static fn(): string => $secret(32),
"MARIADB_ROOT_PASSWORD" => static fn(): string => $secret(32),
"MINIO_ROOT_USER" => static fn(): string => "twminio" . bin2hex(random_bytes(12)),
"MINIO_ROOT_PASSWORD" => static fn(): string => $secret(32),
];
foreach ($required as $name => $factory) {
if (!$hasValue($name)) {
$generated[] = $name . "=" . $factory();
}
}
if ($generated === []) {
exit(0);
}
$prefix = ($content !== "" && !str_ends_with($content, "\n")) ? "\n" : "";
file_put_contents($path, $prefix . implode("\n", $generated) . "\n", FILE_APPEND | LOCK_EX);
' "$ENV_FILE"
chmod 0600 "$ENV_FILE"
}
within_update_window() {
local window
window="$(config_value updateWindow '02:00-04:00')"
@@ -155,7 +118,6 @@ apply_stack() {
mariadb_base_image="$(config_value mariadbBaseImage 'mariadb:11')"
minio_base_image="$(config_value minioBaseImage 'minio/minio:latest')"
compose_project_name="$(config_value composeProjectName 'truckwash-edge-gateway')"
ensure_stack_env
cd "$INSTALL_DIR"
COMPOSE_PROJECT_NAME="$compose_project_name" \
EDGE_AGENT_BASE_IMAGE="$edge_base_image" \
@@ -20,65 +20,6 @@ function worker_read_json_body(): array
return is_array($decoded) ? $decoded : [];
}
function worker_config_path(): string
{
$configuredPath = trim((string)getenv('TRUCKWASH_WORKER_CONFIG_PATH'));
return $configuredPath !== '' ? $configuredPath : '/config/config.json';
}
function worker_expected_token(): string
{
$environmentToken = trim((string)getenv('TRUCKWASH_WORKER_TOKEN'));
if ($environmentToken !== '') {
return $environmentToken;
}
$configPath = worker_config_path();
if (!is_file($configPath)) {
return '';
}
$decoded = json_decode((string)file_get_contents($configPath), true);
return is_array($decoded) ? trim((string)($decoded['agentToken'] ?? '')) : '';
}
function worker_request_token(): string
{
$headerToken = trim((string)($_SERVER['HTTP_X_TRUCKWASH_WORKER_TOKEN'] ?? ''));
if ($headerToken !== '') {
return $headerToken;
}
$authorization = trim((string)($_SERVER['HTTP_AUTHORIZATION'] ?? ''));
if (str_starts_with(strtolower($authorization), 'bearer ')) {
return trim(substr($authorization, 7));
}
return '';
}
function worker_require_authorization(): bool
{
$expectedToken = worker_expected_token();
if ($expectedToken === '') {
worker_json_response(503, [
'message' => 'LAN worker authorization is not configured',
'error_code' => 'EDGE_GATEWAY_WORKER_AUTH_UNCONFIGURED',
]);
return false;
}
if (!hash_equals($expectedToken, worker_request_token())) {
worker_json_response(401, [
'message' => 'Unauthorized',
'error_code' => 'EDGE_GATEWAY_WORKER_UNAUTHORIZED',
]);
return false;
}
return true;
}
function worker_http_get_json(string $url, int $timeoutSeconds = 8): array
{
$ch = curl_init($url);
@@ -183,10 +124,6 @@ try {
}
if ($method === 'POST' && $path === '/discover') {
if (!worker_require_authorization()) {
return;
}
worker_json_response(200, [
'inventory' => [[
'device_id' => 'gateway-runtime-' . substr(sha1($hostname), 0, 10),
@@ -210,10 +147,6 @@ try {
}
if ($method === 'POST' && $path === '/relay/status') {
if (!worker_require_authorization()) {
return;
}
$localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? ''));
$channel = (int)($body['channel'] ?? 0);
$includeInput = (bool)($body['include_input'] ?? $body['includeInput'] ?? false);
@@ -222,10 +155,6 @@ try {
}
if ($method === 'POST' && $path === '/relay/input-status') {
if (!worker_require_authorization()) {
return;
}
$localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? ''));
$channel = (int)($body['channel'] ?? 0);
$input = worker_fetch_shelly_input_state($localIp, $channel);
@@ -237,10 +166,6 @@ try {
}
if ($method === 'POST' && $path === '/relay/switch') {
if (!worker_require_authorization()) {
return;
}
$localIp = trim((string)($body['local_ip'] ?? $body['localIp'] ?? ''));
$channel = (int)($body['channel'] ?? 0);
$on = (bool)($body['on'] ?? false);
@@ -835,9 +835,8 @@ class InvoicingPeriodRoute
$response->add_meta('customer_numbers', $customerNumbers);
}
$paginationOptions = self::getPeriodPaginationOptionsFromRequest();
$includeInvoicePeriodFlags = $this->hasPermission('list_invoice_period_flags');
// Get the invoicing period for the user
$period = self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers, $includeInvoicePeriodFlags);
$period = self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers);
if ($paginationOptions !== null) {
$paginated = self::applyPeriodPagination($period, $paginationOptions);
$period = $paginated['period'];
@@ -1842,12 +1841,7 @@ class InvoicingPeriodRoute
/**
* @throws Exception
*/
private static function getInvoicingPeriod(
string $dateFrom,
string $dateTo,
?array $onlyCustomerNumbers = null,
bool $includeInvoicePeriodFlags = false
): array
private static function getInvoicingPeriod(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers = null): array
{
//$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo)
$onlyCustomerNumbers = $onlyCustomerNumbers !== null
@@ -1894,16 +1888,14 @@ class InvoicingPeriodRoute
$draftOverlay['by_collection_id'] ?? [],
$draftOverlay['by_customer_number'] ?? [],
);
if ($includeInvoicePeriodFlags) {
$types = self::debugGetTime(function () use ($types, $dateFrom, $dateTo, $onlyCustomerNumbers) {
return (new invoice_period_flag_service())->applyFlagsToPeriodTypes(
$types,
$dateFrom,
$dateTo,
$onlyCustomerNumbers
);
}, 'invoice_period_flags');
}
$types = self::debugGetTime(function () use ($types, $dateFrom, $dateTo, $onlyCustomerNumbers) {
return (new invoice_period_flag_service())->applyFlagsToPeriodTypes(
$types,
$dateFrom,
$dateTo,
$onlyCustomerNumbers
);
}, 'invoice_period_flags');
return [
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
@@ -38,7 +38,6 @@ class birdVoiceWebhooksRoute
$this->post('/bird/voice/calls/webhook/inbound', function (): void {
global $response;
self::requirePermission('modules_bird_voice_call_webhooks_trigger');
$client = $this->resolveBirdClient();
$payload = $this->readInboundWebhookPayload();
+4 -3
View File
@@ -198,7 +198,8 @@ class bookingsRoute
$this->post('/admin/bookings/sync', function () {
// Require the user to be logged in
global $response;
$this->requirePermission('sync_bookings');
if ($this->fromRequest('auth_key') !== 'earm8BX4MFTgS6JCNQdqW5EzHUutv2Vx')
$this->requirePermission('sync_bookings');
// Check if the request was successful
$booking = [
'id' => $this->fromRequest('id'),
@@ -231,7 +232,7 @@ class bookingsRoute
(string)$booking['washCertificateEmail'],
(string)$booking['date'],
(string)$booking['department'],
(int)$booking['pickup_bool'],
(string)$booking['pickup_bool'],
(string)$booking['notes'],
(string)$booking['washCertificateStatus'],
(string)$booking['washCertificateUrl'],
@@ -242,7 +243,7 @@ class bookingsRoute
);
},
[
'sync_bookings' => 'Sync bookings from the external system'
'sync_bookings' => 'Sync bookings from the external system NOTE: This permission is only required if the auth_key is not set'
]
);
@@ -503,8 +503,6 @@ class departmentDailyReportsRoute
return;
}
self::requireDepartmentAccess((int)$complaint->department_id->value());
(new logs_o())->add('departments', 'global', 1, $user->id, 'GET_DEPARTMENT_DAILY_REPORT_COMPLAINT', 'Successfully retrieved department daily report complaint');
$response->success($repository->parseComplaint($complaint->asArray()));
@@ -526,10 +524,7 @@ class departmentDailyReportsRoute
'created_at',
])
->listObjectsWithPaginationIfSet(
fn (array $complaint): array => $repository->parseComplaint($complaint),
$repository->forceRestrictFilters([
'department_id' => $user->getGroup()->getDepartments(),
])
fn (array $complaint): array => $repository->parseComplaint($complaint)
)
);
},
@@ -565,8 +560,6 @@ class departmentDailyReportsRoute
return;
}
self::requireDepartmentAccess((int)$complaint->department_id->value());
$updates = [];
if (self::isParametersSet(['department_id'])) {
@@ -585,8 +578,6 @@ class departmentDailyReportsRoute
return;
}
self::requireDepartmentAccess((int)self::getParameter('department_id'));
$updates['department_id'] = (int)self::getParameter('department_id');
}
@@ -706,8 +697,6 @@ class departmentDailyReportsRoute
return;
}
self::requireDepartmentAccess((int)$complaint->department_id->value());
$complaint->deletePermanently();
(new logs_o())->add('departments', 'global', 1, $user->id, 'DELETE_DEPARTMENT_DAILY_REPORT_COMPLAINT', 'Successfully deleted department daily report complaint');
@@ -18,35 +18,6 @@ class departmentLanesRoute
public function run(): void
{
$this->get('/department/lanes/status-toggles', function () {
global $response;
$this->requirePermission('list_department_lanes');
self::requireParameters(['department_id']);
$department_id = (int)self::getParameter('department_id');
self::requireType($department_id, self::type_int());
self::requireMinValue($department_id, 1);
self::requireDepartmentAccess($department_id);
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('department_lanes', 'global', 1, 0, 'LIST_DEPARTMENT_LANE_STATUS_TOGGLES', 'User tried to list department lane status toggles without being logged in');
$response->error('Invalid session', 400);
}
(new logs_o())->add('department_lanes', 'global', 1, $user->id, 'LIST_DEPARTMENT_LANE_STATUS_TOGGLES', 'User listed department lane status toggles for department ' . $department_id);
$response->success(
array_map(
static fn (department_lanes_o $department_lane): array => $department_lane->asArray(),
(new department_lanes_o())->getDepartmentLanes($department_id)
)
);
},
[
'list_department_lanes' => 'List department lane status toggles'
]
);
$this->get('/department/lanes', function () {
// Require the user to be logged in
@@ -65,7 +36,6 @@ class departmentLanesRoute
// Return an error
$response->error('Department lane not found', 404);
}
self::requireDepartmentAccess((int)$department_lane->department->value());
// Log the incident
(new logs_o())->add('department_lanes', 'global', 1, $user->id, 'VIEW_DEPARTMENT_LANE', 'User viewed department lane with id ' . $department_lane->id);
// Return the department lane
@@ -98,13 +68,7 @@ class departmentLanesRoute
$department_lane_o = (new department_lanes_o())->select((int)$department_lane['id']);
// Return the object as an array
return $department_lane_o->asArray();
},
(new department_lanes_o())->forceRestrictFilters(
[
// This makes sure that the user can only see lanes from departments they explicitly have access to
'department' => $user->getGroup()->getDepartments(),
]
)
}
)
);
} else {
@@ -266,27 +230,21 @@ class departmentLanesRoute
// Cache check
$cacheKey = null;
if (defined('redis')) {
// Cache only the default image variant to avoid unbounded cache key growth
// from request-controlled parameters (buttons/current_step/etc.).
$isDefaultVariant = $buttons === null
&& $current_step === 0
&& !(bool)$only_current_step
&& $vehicle_type === null;
if ($isDefaultVariant) {
$cacheParams = [
'department' => $department_id,
'lane' => $lane_id,
'dynamic_image_id' => $dynamic_image_id,
];
$cacheKey = 'dynamic_image:' . md5(json_encode($cacheParams));
$cachedImage = redis->get($cacheKey);
if ($cachedImage) {
header('Content-Type: image/png');
header('Content-Length: ' . strlen($cachedImage));
echo $cachedImage;
exit;
}
$cacheParams = [
'dynamic_image_id' => $dynamic_image_id,
'buttons' => $buttons,
'current_step' => $current_step,
'only_current_step' => (bool)$only_current_step,
'vehicle_type' => $vehicle_type,
'thumb_position' => $thumb_position,
];
$cacheKey = 'dynamic_image_v2:' . md5(json_encode($cacheParams));
$cachedImage = redis->get($cacheKey);
if ($cachedImage) {
header('Content-Type: image/png');
header('Content-Length: ' . strlen($cachedImage));
echo $cachedImage;
exit;
}
}
@@ -378,7 +336,6 @@ class departmentLanesRoute
}
// Check if the required fields are set
if ($name && $department) {
self::requireDepartmentAccess((int)$department);
// Add the department lane
$created_lane = (new department_lanes_o())->add((int)$department, (string)$name, $relay_in_id, $relay_out_id, $relay_machine_id, $relay_machine_program_picker_id, $relay_machine_cleaner_id, $dynamic_image_id, $machine_type_id, $selfserve_enabled);
// Return a success message
@@ -435,14 +392,12 @@ class departmentLanesRoute
// Return an error
$response->error('Department lane not found', 404);
}
self::requireDepartmentAccess((int)$department_lane->department->value());
$was_selfserve_enabled = $department_lane->isSelfServeEnabled();
// Update the department lane fields that are set
if (self::isParametersSet(['name'])) {
$department_lane->name->set($name);
}
if (self::isParametersSet(['department'])) {
self::requireDepartmentAccess((int)$department);
$department_lane->department->set((int)$department);
}
if (self::isParametersSet(['relay_in_id'])) {
@@ -109,7 +109,7 @@ class departmentSelfserveStudioRoute
$this->post('/department/selfserve/studio/simulate', function (): void {
global $response;
$user = $this->requireStudioUser('list_department_selfserve_config_versions');
$user = $this->requireStudioUser('list_department_selfserve_vehicle_conditions');
self::requireParameters(['department', 'lane_id', 'reg']);
$departmentId = (int)self::getParameter('department');
$laneId = (int)self::getParameter('lane_id');
@@ -129,11 +129,11 @@ class departmentSelfserveStudioRoute
$response->error($exception->getMessage(), 422);
}
}, [
'list_department_selfserve_config_versions' => 'Run the self-serve studio simulator',
'list_department_selfserve_vehicle_conditions' => 'Run the self-serve studio simulator',
]);
$this->post('/department/selfserve/studio/path-outcomes/stream', function (): void {
$user = $this->requireStudioUser('list_department_selfserve_config_versions');
$user = $this->requireStudioUser('list_department_selfserve_vehicle_conditions');
self::requireParameters(['department']);
$departmentId = (int)self::getParameter('department');
$this->assertDepartmentAccess($user, $departmentId);
@@ -169,12 +169,12 @@ class departmentSelfserveStudioRoute
}
exit;
}, [
'list_department_selfserve_config_versions' => 'Stream grouped self-serve studio question path outcome progress',
'list_department_selfserve_vehicle_conditions' => 'Stream grouped self-serve studio question path outcome progress',
]);
$this->post('/department/selfserve/studio/path-outcomes', function (): void {
global $response;
$user = $this->requireStudioUser('list_department_selfserve_config_versions');
$user = $this->requireStudioUser('list_department_selfserve_vehicle_conditions');
self::requireParameters(['department']);
$departmentId = (int)self::getParameter('department');
$this->assertDepartmentAccess($user, $departmentId);
@@ -192,30 +192,7 @@ class departmentSelfserveStudioRoute
$response->error($exception->getMessage(), 422);
}
}, [
'list_department_selfserve_config_versions' => 'Project grouped self-serve studio question path outcomes',
]);
$this->post('/department/selfserve/studio/path-confirmations', function (): void {
global $response;
$user = $this->requireStudioUser('edit_department_selfserve_config_versions');
self::requireParameters(['department', 'path_signature']);
$departmentId = (int)self::getParameter('department');
$this->assertDepartmentAccess($user, $departmentId);
try {
$payload = self::getParametersAsArray();
$action = strtolower(trim((string)($payload['action'] ?? 'confirm')));
$service = new selfserve_studio_graph();
$result = in_array($action, ['delete', 'reset', 'clear'], true)
? $service->resetPathConfirmation($departmentId, $payload)
: $service->confirmPathOutcome($departmentId, $payload, (int)$user->id);
(new logs_o())->add('selfserve_studio', $departmentId, 1, $user->id, 'CONFIRM_STUDIO_PATH', 'Updated self-serve studio path confirmation');
$response->success($result);
} catch (\RuntimeException $exception) {
$response->error($exception->getMessage(), 422);
}
}, [
'edit_department_selfserve_config_versions' => 'Confirm or reset projected self-serve studio answer paths',
'list_department_selfserve_vehicle_conditions' => 'Project grouped self-serve studio question path outcomes',
]);
$this->post('/department/selfserve/studio/publish', function (): void {
@@ -329,8 +306,6 @@ class departmentSelfserveStudioRoute
'can_publish' => $this->hasPermission('publish_department_selfserve_config_versions'),
'can_rollback' => $this->hasPermission('rollback_department_selfserve_config_versions'),
'can_simulate' => $this->hasPermission('list_department_selfserve_vehicle_conditions'),
'can_add_department_lane' => $this->hasPermission('add_department_lane'),
'can_edit_department_lane' => $this->hasPermission('edit_department_lane'),
'modules_shelly_config' => $this->hasPermission('modules_shelly_config'),
'can_manage_gateways' => $this->hasPermission('modules_shelly_config'),
'can_run_gateway_destructive_actions' => $this->hasPermission('modules_shelly_config'),
@@ -493,14 +493,11 @@ class departmentSelfserveTasksRoute
}
}
$task_attachments = $task_o->listAttachments();
$task_attachment_ids = array_map(static fn($attachment) => (int)$attachment->id, $task_attachments);
if (!in_array($attachment_id, $task_attachment_ids, true)) {
$attachment = $task_o->getAttachment($attachment_id);
if (!$attachment->exists()) {
$response->error('Attachment not found', 404);
}
$attachment = $task_o->getAttachment($attachment_id);
$attachment_store = new attachment_store();
$attachments = new attachments();
$attachment_formatted = $attachments->format($attachment);
@@ -623,12 +620,6 @@ class departmentSelfserveTasksRoute
$this->forbidDepartmentAccess((int)$task_o->department->value());
}
$task_attachments = $task_o->listAttachments();
$task_attachment_ids = array_map(static fn($attachment) => (int)$attachment->id, $task_attachments);
if (!in_array($attachment_id, $task_attachment_ids, true)) {
$response->error('Attachment not found', 404);
}
$task_o->removeAttachment($attachment_id);
(new logs_o())->add('department_selfserve_tasks', (int)$task_o->department->value(), 1, $user->id, 'DELETE_TASK_ATTACHMENT', 'User deleted attachment ID: ' . $attachment_id . ' for task ID: ' . $task_id);
@@ -13,7 +13,6 @@ use objects\customer_vehicles_o;
use objects\department_lanes_o;
use objects\department_selfserve_tasks_o;
use objects\department_selfserve_vehicle_conditions_o;
use objects\departments_o;
use objects\logs_o;
use traits\route_t;
@@ -132,9 +131,9 @@ class departmentSelfserveVehicleConditionsRoute
$lane_id = (int)self::getParameter('lane_id');
$reg = selfserve::standardize_registration((string)self::getParameter('reg'));
$lane = $this->assertLaneAccess($user, $lane_id, $has_global, $has_own);
$lane = $this->assertLaneAccess($user, $lane_id, $has_global);
$customer_number = null;
if ($has_own && (!$has_global || !$this->userHasLaneDepartmentAccess($user, $lane))) {
if (!$has_global && $has_own) {
$customer_number = $this->requireAuthenticatedCustomerNumber($user, 'list_department_selfserve_vehicle_conditions');
}
$vehicle_type_id = $this->resolveVehicleTypeIdFromQuery();
@@ -172,7 +171,7 @@ class departmentSelfserveVehicleConditionsRoute
try {
if (self::isParametersSet(['session_id'])) {
$summary = $flow->getSessionSummary((int)self::getParameter('session_id'));
$this->assertSummaryAccess($user, $summary, $has_global, $has_own, 'list_department_selfserve_vehicle_conditions');
$this->assertSummaryAccess($user, $summary, $has_global, 'list_department_selfserve_vehicle_conditions');
if ($this->shouldRefreshSummaryForVehicleType($summary, $vehicle_type_id)) {
$summary = $flow->synchronizeSession(
@@ -194,9 +193,9 @@ class departmentSelfserveVehicleConditionsRoute
$lane_id = (int)self::getParameter('lane_id');
$reg = selfserve::standardize_registration((string)self::getParameter('reg'));
$lane = $this->assertLaneAccess($user, $lane_id, $has_global, $has_own);
$this->assertLaneAccess($user, $lane_id, $has_global);
$customer_number = null;
if ($has_own && (!$has_global || !$this->userHasLaneDepartmentAccess($user, $lane))) {
if (!$has_global && $has_own) {
$customer_number = $this->requireAuthenticatedCustomerNumber($user, 'list_department_selfserve_vehicle_conditions');
}
@@ -205,7 +204,7 @@ class departmentSelfserveVehicleConditionsRoute
} else {
$summary = $flow->getLatestSessionSummary($lane_id, $reg);
}
$this->assertSummaryAccess($user, $summary, $has_global, $has_own, 'list_department_selfserve_vehicle_conditions');
$this->assertSummaryAccess($user, $summary, $has_global, 'list_department_selfserve_vehicle_conditions');
$response->success($summary);
} catch (\RuntimeException $e) {
$response->error($e->getMessage(), 404);
@@ -528,13 +527,7 @@ class departmentSelfserveVehicleConditionsRoute
return $default;
}
private function assertLaneAccess(
object $user,
int $laneId,
bool $hasGlobalPermission = true,
bool $hasOwnPermission = false,
string $elevatedPermission = 'list_department_selfserve_vehicle_conditions'
): department_lanes_o
private function assertLaneAccess(object $user, int $laneId, bool $hasGlobalPermission): department_lanes_o
{
global $response;
@@ -543,77 +536,14 @@ class departmentSelfserveVehicleConditionsRoute
$response->error('Department lane not found', 404);
}
if ($hasGlobalPermission && $this->userHasLaneDepartmentAccess($user, $lane)) {
return $lane;
}
if ($hasOwnPermission && $this->isCustomerSelfServeLaneEnabled($lane)) {
return $lane;
}
if ($hasGlobalPermission) {
$lane_department_id = (int)$lane->department->value();
$this->forbidDepartmentAccess($lane_department_id);
}
$response->forbidden([$elevatedPermission]);
}
private function userHasLaneDepartmentAccess(object $user, department_lanes_o $lane): bool
{
$lane_department_id = (int)$lane->department->value();
$authorized_department_ids = array_values(array_filter(
array_map('intval', (array)$user->getGroup()->getDepartments()),
static fn(int $department_id): bool => $department_id > 0
));
return in_array($lane_department_id, $authorized_department_ids, true);
}
private function isCustomerSelfServeLaneEnabled(department_lanes_o $lane): bool
{
try {
if (!$lane->isSelfServeEnabled()) {
return false;
$authorized_department_ids = $user->getGroup()->getDepartments();
if (!in_array((int)$lane->department->value(), $authorized_department_ids, true)) {
$this->forbidDepartmentAccess((int)$lane->department->value());
}
$department = (new departments_o())->select((int)$lane->department->value());
return $department->exists() && $department->getSelfServeEnabled();
} catch (\Throwable) {
return false;
}
}
private function summaryBelongsToCustomer(object $user, array $summary): bool
{
$session_customer_number = $summary['session']['customer_number'] ?? null;
if ($session_customer_number !== null && (int)$session_customer_number === (int)$user->customer_number->value()) {
return true;
}
$reg = (string)($summary['session']['reg'] ?? '');
$vehicle_o = (new customer_vehicles_o())->selectByPlate($reg);
return $vehicle_o->exists() && (int)$vehicle_o->customer_id->value() === (int)$user->customer_number->value();
}
private function summaryDepartmentId(array $summary): int
{
return (int)($summary['lane']['department'] ?? $summary['session']['department_id'] ?? 0);
}
private function userHasSummaryDepartmentAccess(object $user, array $summary): bool
{
$lane_department = $this->summaryDepartmentId($summary);
if ($lane_department <= 0) {
return false;
}
$authorized_department_ids = array_values(array_filter(
array_map('intval', (array)$user->getGroup()->getDepartments()),
static fn(int $department_id): bool => $department_id > 0
));
return in_array($lane_department, $authorized_department_ids, true);
return $lane;
}
private function requireAuthenticatedCustomerNumber(object $user, string $elevatedPermission): int
@@ -628,26 +558,28 @@ class departmentSelfserveVehicleConditionsRoute
return $customer_number;
}
private function assertSummaryAccess(
object $user,
array $summary,
bool $hasGlobalPermission,
bool $hasOwnPermission,
string $elevatedPermission
): void
private function assertSummaryAccess(object $user, array $summary, bool $hasGlobalPermission, string $elevatedPermission): void
{
global $response;
if ($hasGlobalPermission && $this->userHasSummaryDepartmentAccess($user, $summary)) {
return;
}
if ($hasOwnPermission && $this->summaryBelongsToCustomer($user, $summary)) {
return;
}
if ($hasGlobalPermission) {
$this->forbidDepartmentAccess($this->summaryDepartmentId($summary));
$authorized_department_ids = $user->getGroup()->getDepartments();
$lane_department = (int)($summary['lane']['department'] ?? 0);
if (!in_array($lane_department, $authorized_department_ids, true)) {
$this->forbidDepartmentAccess($lane_department);
}
return;
}
$session_customer_number = $summary['session']['customer_number'] ?? null;
if ($session_customer_number !== null && (int)$session_customer_number === (int)$user->customer_number->value()) {
return;
}
$reg = (string)($summary['session']['reg'] ?? '');
$vehicle_o = (new customer_vehicles_o())->selectByPlate($reg);
if ($vehicle_o->exists() && (int)$vehicle_o->customer_id->value() === (int)$user->customer_number->value()) {
return;
}
$response->forbidden([$elevatedPermission]);
+8 -10
View File
@@ -17,7 +17,7 @@ class departmentsRoute
{
use route_t;
private function buildDepartmentListFilters(departments_o $departments, bool $canListArchived): array
private function buildDepartmentListFilters(departments_o $departments, bool $canListArchived): string
{
global $response;
@@ -41,7 +41,7 @@ class departmentsRoute
$filters['visible'] = 1;
$filters['archived'] = $archived;
return $filters;
return $departments->array_to_filters($filters);
}
private static function isTruthyBooleanValue(mixed $value): bool
@@ -307,8 +307,6 @@ class departmentsRoute
// Return an error
$response->error('Department not found', 404);
}
// Check if the user has access to the department
self::requireDepartmentAccess((string)$department->id);
// Get the department variable
$department_variables = (new department_variables_o())->selectDepartment($department->id);
$enabled = $department_variables->getVariable('selfserve_enabled');
@@ -323,8 +321,7 @@ class departmentsRoute
}
},
[
'view_department_selfserve_enabled' => 'View if department self-serve is enabled',
'department_access_:id' => 'Access the department'
'view_department_selfserve_enabled' => 'View if department self-serve is enabled'
]
);
@@ -348,7 +345,7 @@ class departmentsRoute
$response->error('Department not found', 404);
}
// Check if the user has access to the department
self::requireDepartmentAccess((string)$department->id);
self::requireDepartmentAccess($department);
// Set the department variable
$department_variables = (new department_variables_o())->selectDepartment($department->id);
$enabled = self::getParameter('enabled') === 'true' || self::getParameter('enabled') === true || self::getParameter('enabled') === 1 || self::getParameter('enabled') === '1';
@@ -557,6 +554,7 @@ class departmentsRoute
]
);
}
protected function syncDepartmentSelfServeRelayStates(int $departmentId, bool $enabled): void
{
if (!$enabled) {
@@ -581,13 +579,13 @@ class departmentsRoute
// Self-serve enabled: keep machine stack off.
$this->setOptionalLaneRelayState($lane, 'relay_machine_program_picker_id', static function () use ($lane): void {
$lane->setMachineProgramPickerRelayStatus(false);
$lane->setMachineProgramPickerRelayStatusHard(false);
});
$this->setOptionalLaneRelayState($lane, 'relay_machine_cleaner_id', static function () use ($lane): void {
$lane->setMachineCleanerRelayStatus(false);
$lane->setMachineCleanerRelayStatusHard(false);
});
try {
$lane->setMachineRelayStatus(false);
$lane->setMachineRelayStatusHard(false);
} catch (\Throwable) {}
}
}
@@ -239,7 +239,7 @@ class economicInvoiceRoute
}
$queue = new economic_transfer_queue();
$job = $queue->getJobByIdForUser((int)$job_id, (int)$user->id);
$job = $queue->getJobById((int)$job_id);
if ($job === null || ($job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_ORDER_DRAFT_EXPORT) {
$response->error('Draft export queue job not found', 404);
}
@@ -264,13 +264,13 @@ class economicInvoiceRoute
}
$queue = new economic_transfer_queue();
$existing_job = $queue->getJobByIdForUser((int)$job_id, (int)$user->id);
$existing_job = $queue->getJobById((int)$job_id);
if ($existing_job === null || ($existing_job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_ORDER_DRAFT_EXPORT) {
$response->error('Draft export queue job not found', 404);
}
try {
$job = $queue->retryJobForUser((int)$job_id, (int)$user->id);
$job = $queue->retryJob((int)$job_id);
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
}
@@ -298,7 +298,7 @@ class economicInvoiceRoute
}
$queue = new economic_transfer_queue();
$job = $queue->getJobByIdForUser((int)$job_id, (int)$user->id);
$job = $queue->getJobById((int)$job_id);
if ($job === null || ($job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_ORDER_INVOICE_EXPORT) {
$response->error('Invoice export queue job not found', 404);
}
@@ -323,13 +323,13 @@ class economicInvoiceRoute
}
$queue = new economic_transfer_queue();
$existing_job = $queue->getJobByIdForUser((int)$job_id, (int)$user->id);
$existing_job = $queue->getJobById((int)$job_id);
if ($existing_job === null || ($existing_job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_ORDER_INVOICE_EXPORT) {
$response->error('Invoice export queue job not found', 404);
}
try {
$job = $queue->retryJobForUser((int)$job_id, (int)$user->id);
$job = $queue->retryJob((int)$job_id);
} catch (\Throwable $e) {
$response->error($e->getMessage(), 400);
}
@@ -22,7 +22,8 @@ class moduleLimbleRoute
$this->post('/modules/limble/webhook/task', function () {
global $response;
$slack = new slack();
self::requirePermission('modules_limble_webhooks_task');
//TODO: Add authentication of some sort here
//self::requirePermission('modules_limble_webhooks_task');
// Check if the module is enabled
$limble = new limble();
$limble->requireModuleEnabled();
@@ -44,7 +45,7 @@ class moduleLimbleRoute
global $response;
$slack = new slack();
$slack->send_message('Limble Tasks Endpoint Triggered', 'Limble Tasks');
self::requirePermission('modules_limble_tasks');
//self::requirePermission('modules_limble_tasks');
// Check if the module is enabled
$limble = new limble();
$limble->requireModuleEnabled();
@@ -60,4 +61,4 @@ class moduleLimbleRoute
]
);
}
}
}
@@ -22,7 +22,6 @@ use objects\logs_o;
use objects\orders_o;
use objects\customer_vehicles_o;
use objects\selfserve_wash_sessions_o;
use objects\selfserve_wash_session_tasks_o;
use objects\stripe_module_customers_o;
use objects\users_o;
use traits\route_t;
@@ -31,7 +30,6 @@ class moduleSelfServeRoute
{
use route_t;
private const MAX_GATE_OPEN_TOGGLE_AFTER_SECONDS = 5;
private const CUSTOMER_SELFSERVE_PERMISSION = 'list_own_department_selfserve_vehicle_conditions';
public function run(): void
@@ -64,60 +62,6 @@ class moduleSelfServeRoute
]
);
$this->put('/modules/self-serve/lane/status', function () {
global $response;
self::requirePermission('modules_selfserve_lane_status_set');
self::requireParameters(['lane_id', 'enabled']);
$lane_id = (int)self::getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$department_lane = (new department_lanes_o())->select($lane_id);
if (!$department_lane->exists()) {
$response->error('Department lane not found', 404);
}
self::requireDepartmentAccess((int)$department_lane->department->value());
$enabled = $this->requestedBoolean('enabled');
$target_status = $enabled ? selfserve_lane_status::AVAILABLE : selfserve_lane_status::MAINTENANCE;
$lane = (new selfserve())->lane($lane_id);
$lane->setLaneStatus($target_status);
$user = (new authentication())->get_user();
$machine_status_audit = [
'modified_at' => date(DATE_ATOM),
'modified_by_user_id' => $user ? (int)$user->id : null,
'modified_by_name' => $this->machineStatusAuditUserName($user),
];
$lane->setLaneStatusAudit($machine_status_audit);
(new logs_o())->add(
'selfserve',
'global',
1,
$user ? $user->id : 0,
'SET_LANE_MACHINE_STATUS',
'User set self-serve lane ' . $lane_id . ' machine status to ' . $target_status->name
);
$status = (string)$lane->getLaneStatus()->name;
$response->success([
'id' => $lane->id,
'status' => $status,
'machine_status_enabled' => department_lanes_o::isOperationalStatusName($status),
'machine_status_audit' => $machine_status_audit,
'machine_status_modified_at' => $machine_status_audit['modified_at'],
'machine_status_modified_by' => $machine_status_audit['modified_by_name'],
'machine_status_modified_by_user_id' => $machine_status_audit['modified_by_user_id'],
'lane' => $department_lane->asArray(),
]);
},
[
'modules_selfserve_lane_status_set' => 'Set self-serve lane machine status',
]
);
/** Modules > Self Serve > Lane > Wash > In-progress details */
$this->get('/modules/self-serve/lane/wash/in-progress', function () {
global $response;
@@ -125,7 +69,7 @@ class moduleSelfServeRoute
$lane_id = (int)$this->getParameter('lane_id');
self::requireType($lane_id, self::type_int());
self::requireMinValue($lane_id, 1);
$customer_scope = $this->requireInProgressWashDetailsAccess($lane_id);
$customer_scope = $this->requireInProgressWashDetailsAccess();
$build_customer = static function (?int $customer_number): ?array {
if ($customer_number === null || $customer_number <= 0) {
@@ -335,23 +279,6 @@ class moduleSelfServeRoute
]
);
/** Modules > Self Serve > Lane > Wash > My active wash */
$this->get('/modules/self-serve/lane/wash/my-active-wash', function () {
global $response;
$customer_number = $this->requireMyActiveWashCustomerNumber();
$session = $this->findLatestActiveSelfServeSessionForCustomer($customer_number);
if (!$session->exists()) {
$response->error('No active self-serve wash found.', 404);
}
$response->success($this->buildActiveSelfServeSessionResponse($session));
},
[
'list_own_department_selfserve_vehicle_conditions' => 'View the authenticated customer\'s active self-serve wash',
]
);
/** Modules > Self Serve > Sessions */
$this->get('/modules/self-serve/sessions', function () {
global $response;
@@ -476,34 +403,18 @@ class moduleSelfServeRoute
self::requireMinValue($lane_id, 1);
$commandParam = (string)$this->getParameter($param_command);
self::requireType($commandParam, self::type_string());
$command = selfserve_lane_command::tryFrom($commandParam);
if ($command === null) {
$response->error("Invalid command: " . $commandParam);
}
// Get the lane and command
$lane = $selfserve->lane($lane_id);
// Preserve not-found behavior before evaluating elevated/customer alternatives.
if (empty($lane->department_lane) || empty($lane->department_lane->department)) {
$response->error('Lane department not found', 404);
}
$customer_number = $this->resolveEffectiveCustomerNumber();
$customer_number = $customer_number === null ? 0 : (int)$customer_number;
[
$allow_customer_self_serve,
$requires_active_wash,
$allow_department_active_wash
] = $this->customerSelfServeCommandAccessRequirements($command);
$this->requireSelfServeLaneDepartmentOrCustomerAccess(
$lane,
$customer_number,
$allow_customer_self_serve,
$requires_active_wash,
$allow_department_active_wash
);
// If the user has the bypass permission, set the lane to bypass customer number validation
if (self::hasPermission('modules_selfserve_lane_command_bypass_customer_number_validation')) {
$lane->setBypassCustomerNumberValidation(true);
}
$command = selfserve_lane_command::tryFrom($commandParam);
if ($command === null) {
$response->error("Invalid command: " . $commandParam);
}
$customer_number = $this->resolveEffectiveCustomerNumber();
$customer_number = $customer_number === null ? 0 : (int)$customer_number;
// Require permissions for specific commands
switch ($command) {
case selfserve_lane_command::START:
@@ -520,26 +431,31 @@ class moduleSelfServeRoute
$customer_number,
'modules_selfserve_lane_command_execute_stop',
true,
true,
false
true
);
break;
case selfserve_lane_command::RESERVE:
$this->requireOperatorLaneCommandPermission(
$this->requireSelfServeLaneCommandPermission(
$lane,
'modules_selfserve_lane_command_execute_reserve'
$customer_number,
'modules_selfserve_lane_command_execute_reserve',
false
);
break;
case selfserve_lane_command::RELEASE:
$this->requireOperatorLaneCommandPermission(
$this->requireSelfServeLaneCommandPermission(
$lane,
'modules_selfserve_lane_command_execute_release'
$customer_number,
'modules_selfserve_lane_command_execute_release',
false
);
break;
case selfserve_lane_command::RESET:
$this->requireOperatorLaneCommandPermission(
$this->requireSelfServeLaneCommandPermission(
$lane,
'modules_selfserve_lane_command_execute_reset'
$customer_number,
'modules_selfserve_lane_command_execute_reset',
false
);
break;
case selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE:
@@ -560,12 +476,10 @@ class moduleSelfServeRoute
// Execute the command
try {
$this->applyShellyTransportOverride($lane);
$subuser = (new authentication())->get_subuser();
$args = new \modules\selfserve\classes\selfserve_lane_command_arguments();
$args->setParameters([
...$this->getParametersAsArray(), // Pass all parameters
'customer_number' => $customer_number, // Get customer number from request user
'subuser_id' => $subuser === false ? null : (int)$subuser->id,
]);
$lane->execute($command, $args);
$response->success([
@@ -634,56 +548,24 @@ class moduleSelfServeRoute
$this->requireSelfServeLaneAccess(
$lane,
$customer_number === null ? 0 : (int)$customer_number,
['modules_selfserve_lane_services_set_allowed'],
false
['modules_selfserve_lane_services_set_allowed']
);
$session_task_services = [];
$latest_session = (new selfserve_wash_sessions_o())->selectLatestOpenByLane(
$lane_id,
$customer_number === null || (int)$customer_number <= 0 ? null : (int)$customer_number
);
if (!$latest_session->exists() && $customer_number !== null && (int)$customer_number > 0) {
$latest_session = (new selfserve_wash_sessions_o())->selectLatestOpenByLane($lane_id);
}
if ($latest_session->exists()) {
foreach ((new selfserve_wash_session_tasks_o())->listBySession((int)$latest_session->id) as $session_task) {
$task_id = (int)($session_task['task_id'] ?? 0);
if ($task_id > 0) {
$services = $session_task['services'] ?? [];
if (is_string($services)) {
$decoded_services = json_decode($services, true);
$services = json_last_error() === JSON_ERROR_NONE && is_array($decoded_services)
? $decoded_services
: [];
}
$session_task_services[$task_id] = is_array($services) ? $services : [];
}
}
}
$allowed_services = [];
$merge_services = static function (array $services) use (&$allowed_services): void {
foreach ($services as $srv) {
$name = strtoupper((string)$srv);
if (!in_array($name, $allowed_services, true)) {
$allowed_services[] = $name;
}
}
};
foreach ($task_ids as $tid) {
if ($tid <= 0) continue;
if (array_key_exists($tid, $session_task_services)) {
$merge_services($session_task_services[$tid]);
continue;
}
$t = new \objects\department_selfserve_tasks_o();
$t->select($tid);
if (!$t->exists()) continue; // ignore unknown ids
// Validate the task belongs to the same lane
if ((int)$t->lane->value() !== $lane_id) continue;
// Merge services (if any)
$merge_services((array)$t->services->value());
$services = (array)$t->services->value();
foreach ($services as $srv) {
$name = strtoupper((string)$srv);
if (!in_array($name, $allowed_services, true)) {
$allowed_services[] = $name;
}
}
}
// Persist on lane cache (overwrites previous allowed services)
try {
@@ -1000,7 +882,12 @@ class moduleSelfServeRoute
}
$lane = $selfserve->lane($lane_id);
$customer_number = $this->resolveEffectiveCustomerNumber();
self::requirePermission('modules_selfserve_lane_relay_enable_machine');
$this->requireSelfServeLaneAccess(
$lane,
$customer_number === null ? 0 : (int)$customer_number,
['modules_selfserve_lane_relay_enable_machine'],
true
);
try {
$this->applyShellyTransportOverride($lane);
$lane->turnOnRelay(selfserve_lane_relay::MACHINE, $duration);
@@ -1296,7 +1183,7 @@ class moduleSelfServeRoute
]);
}
private function requireInProgressWashDetailsAccess(int $lane_id): ?int
private function requireInProgressWashDetailsAccess(): ?int
{
global $response;
@@ -1306,11 +1193,6 @@ class moduleSelfServeRoute
}
if (self::hasPermission('modules_selfserve_lane_wash_in_progress_view')) {
$department_lane = (new department_lanes_o())->select($lane_id);
if (!$department_lane->exists()) {
$response->error('Department lane not found', 404);
}
self::requireDepartmentAccess((string)$department_lane->department->value());
return null;
}
@@ -1378,188 +1260,6 @@ class moduleSelfServeRoute
return null;
}
private function requireMyActiveWashCustomerNumber(): int
{
global $response;
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Authentication failed. Invalid or missing token.', 401);
}
if (!self::hasPermission(self::CUSTOMER_SELFSERVE_PERMISSION)) {
$this->emitForbidden([self::CUSTOMER_SELFSERVE_PERMISSION]);
}
$customer_number = $this->resolveEffectiveCustomerNumber();
if ($customer_number === null || $customer_number <= 0) {
$response->error('No customer number found for authenticated user.', 404);
}
return (int)$customer_number;
}
private function findLatestActiveSelfServeSessionForCustomer(int $customer_number): selfserve_wash_sessions_o
{
if ($customer_number <= 0) {
return new selfserve_wash_sessions_o();
}
$rows = (new selfserve_wash_sessions_o())->getFieldsWhere([
'customer_number' => $customer_number,
'completed_at' => null,
'deleted_at' => null,
], ['id', 'status']);
$active_statuses = $this->activeSelfServeWashSessionStatusValues();
$rows = array_values(array_filter(
$rows,
static fn(array $row): bool => in_array((string)($row['status'] ?? ''), $active_statuses, true)
));
if ($rows === []) {
return new selfserve_wash_sessions_o();
}
usort($rows, static fn(array $a, array $b): int => (int)$b['id'] <=> (int)$a['id']);
return (new selfserve_wash_sessions_o())->select((int)$rows[0]['id']);
}
/**
* @return array<int,string>
*/
private function activeSelfServeWashSessionStatusValues(): array
{
return array_map(
static fn(selfserve_wash_session_status $status): string => $status->value,
[
selfserve_wash_session_status::MACHINE_RELAY_ENABLED,
selfserve_wash_session_status::READY_FOR_MACHINE_START,
selfserve_wash_session_status::MACHINE_STARTED,
selfserve_wash_session_status::PENDING_QUESTIONS,
selfserve_wash_session_status::MACHINE_NOT_ALLOWED,
]
);
}
/**
* @return array<string,mixed>
*/
private function buildActiveSelfServeSessionResponse(selfserve_wash_sessions_o $session): array
{
$lane_id = (int)$session->lane_id->value();
$customer_number = $session->customer_number->value() === null ? null : (int)$session->customer_number->value();
$vehicle_id = $session->vehicle_id->value() === null ? null : (int)$session->vehicle_id->value();
$session_reg = trim((string)$session->reg->value());
if ($session_reg === '') {
$session_reg = null;
}
$machine_start_triggered_at = $session->machine_start_triggered_at->value() === null ? null : (string)$session->machine_start_triggered_at->value();
$wash_started_at = $session->wash_started_at->value() === null ? null : (string)$session->wash_started_at->value();
if ($wash_started_at === null) {
$wash_started_at = $machine_start_triggered_at;
}
$machine_relay_enabled = (bool)$session->machine_relay_enabled->value();
$included_minutes = null;
if ($machine_relay_enabled) {
$included_minutes = (int)(new selfserve())->config->machine_wash_minutes_included->getVariableValue();
if ($included_minutes < 0) {
$included_minutes = 0;
}
}
return [
'lane_id' => $lane_id,
'status' => (string)$session->status->value(),
'in_progress' => true,
'elapsed_minutes' => $session->getElapsedMinutes(),
'session' => [
'id' => (int)$session->id,
'status' => (string)$session->status->value(),
'department_id' => $session->department_id->value() === null ? null : (int)$session->department_id->value(),
'lane_id' => $lane_id,
'reg' => $session_reg,
'customer_number' => $customer_number,
'vehicle_id' => $vehicle_id,
'vehicle_type_id' => $session->vehicle_type_id->value() === null ? null : (int)$session->vehicle_type_id->value(),
'included_minutes' => $included_minutes ?? 0,
'machine_type_id' => $session->machine_type_id->value() === null ? null : (int)$session->machine_type_id->value(),
'machine_relay_enabled' => $machine_relay_enabled,
'machine_relay_enabled_at' => $session->machine_relay_enabled_at->value() === null ? null : (string)$session->machine_relay_enabled_at->value(),
'machine_start_triggered' => (bool)$session->machine_start_triggered->value(),
'machine_start_triggered_at' => $machine_start_triggered_at,
'wash_started_at' => $wash_started_at,
'created_at' => (string)$session->created_at->value(),
'updated_at' => $session->updated_at->value() === null ? null : (string)$session->updated_at->value(),
],
'customer' => $this->buildInProgressWashCustomerPayload($customer_number),
'vehicle' => $this->buildInProgressWashVehiclePayload($vehicle_id, $session_reg),
];
}
/**
* @return array<string,mixed>|null
*/
private function buildInProgressWashCustomerPayload(?int $customer_number): ?array
{
if ($customer_number === null || $customer_number <= 0) {
return null;
}
$customer_obj = (new users_o())->getUserByCustomerNumber($customer_number);
if ($customer_obj->exists()) {
return [
'id' => (int)$customer_obj->id,
'customer_number' => $customer_number,
'display_name' => $customer_obj->display_name->value() === null ? null : (string)$customer_obj->display_name->value(),
'email' => $customer_obj->email->value() === null ? null : (string)$customer_obj->email->value(),
'phone_country_code' => $customer_obj->phone_country_code->value() === null ? null : (int)$customer_obj->phone_country_code->value(),
'phone' => $customer_obj->phone->value() === null ? null : (string)$customer_obj->phone->value(),
];
}
return [
'id' => null,
'customer_number' => $customer_number,
'display_name' => null,
'email' => null,
'phone_country_code' => null,
'phone' => null,
];
}
/**
* @return array<string,mixed>|null
*/
private function buildInProgressWashVehiclePayload(?int $vehicle_id, ?string $reg): ?array
{
$vehicle_obj = null;
if ($vehicle_id !== null && $vehicle_id > 0) {
$tmp_vehicle = (new customer_vehicles_o())->select($vehicle_id);
if ($tmp_vehicle->exists()) {
$vehicle_obj = $tmp_vehicle;
}
}
if ($vehicle_obj === null && $reg !== null && trim($reg) !== '') {
$tmp_vehicle = (new customer_vehicles_o())->selectByPlate(trim($reg));
if ($tmp_vehicle->exists()) {
$vehicle_obj = $tmp_vehicle;
}
}
if ($vehicle_obj === null) {
return null;
}
return [
'id' => (int)$vehicle_obj->id,
'customer_id' => (int)$vehicle_obj->customer_id->value(),
'type' => (int)$vehicle_obj->type->value(),
'reg' => (string)$vehicle_obj->reg->value(),
'reference' => $vehicle_obj->reference->value() === null ? null : (string)$vehicle_obj->reference->value(),
];
}
/**
* @param array<string,mixed> $status
* @param array<string,mixed> $extra
@@ -1611,51 +1311,6 @@ class moduleSelfServeRoute
$lane->setShellyTransportOverride($transport);
}
/**
* @return array{0:bool,1:bool,2:bool} [customer self-serve allowed, active wash required, department active wash fallback allowed]
*/
private function customerSelfServeCommandAccessRequirements(selfserve_lane_command $command): array
{
return match ($command) {
selfserve_lane_command::START => [true, false, false],
selfserve_lane_command::STOP => [true, true, false],
selfserve_lane_command::OPEN_PROPERTY_ACCESS_GATE,
selfserve_lane_command::OPEN_PROPERTY_EXIT_GATE => [true, true, true],
selfserve_lane_command::RESERVE,
selfserve_lane_command::RELEASE,
selfserve_lane_command::RESET => [false, false, false],
};
}
private function requireSelfServeLaneDepartmentOrCustomerAccess(
selfserve_lane $lane,
int $customer_number,
bool $allow_customer_self_serve,
bool $requires_active_wash = false,
bool $allow_department_active_wash = false
): void {
$department_id = $this->departmentIdForLane($lane);
if ($department_id > 0 && $this->hasDepartmentAccess((string)$department_id)) {
return;
}
if ($allow_customer_self_serve) {
$customer_allowed = $requires_active_wash
? $this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, $allow_department_active_wash)
: $this->canCustomerUseSelfServeLane($lane, $customer_number);
if ($customer_allowed) {
return;
}
}
$missing_permissions = $department_id > 0 ? ['department_access_' . $department_id] : [];
if ($allow_customer_self_serve) {
$missing_permissions[] = self::CUSTOMER_SELFSERVE_PERMISSION;
}
$this->emitForbidden($missing_permissions);
}
/**
* @param array<int,string> $permissions
*/
@@ -1703,8 +1358,7 @@ class moduleSelfServeRoute
int $customer_number,
string $command_permission,
bool $allow_customer_self_serve,
bool $requires_active_wash = false,
bool $allow_department_active_wash = false
bool $requires_active_wash = false
): void {
$elevated_permissions = [
'modules_selfserve_lane_command_execute',
@@ -1714,9 +1368,9 @@ class moduleSelfServeRoute
return;
}
if ($allow_customer_self_serve && $this->isSelfServeModuleEnabled()) {
if ($allow_customer_self_serve) {
$customer_allowed = $requires_active_wash
? $this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, $allow_department_active_wash)
? $this->canCustomerUseActiveSelfServeLane($lane, $customer_number)
: $this->canCustomerUseSelfServeLane($lane, $customer_number);
if ($customer_allowed) {
@@ -1731,18 +1385,6 @@ class moduleSelfServeRoute
);
}
private function requireOperatorLaneCommandPermission(selfserve_lane $lane, string $command_permission): void
{
if (empty($lane->department_lane) || empty($lane->department_lane->department)) {
global $response;
$response->error('Lane department not found', 404);
}
self::requireDepartmentAccess((string)$lane->department_lane->department->value());
self::requirePermission('modules_selfserve_lane_command_execute');
self::requirePermission($command_permission);
}
private function requirePropertyGateCommandPermission(string $permission, selfserve_lane $lane, int $customer_number): void
{
$elevated_permissions = [
@@ -1760,15 +1402,6 @@ class moduleSelfServeRoute
$this->emitForbidden([...$elevated_permissions, self::CUSTOMER_SELFSERVE_PERMISSION]);
}
protected function isSelfServeModuleEnabled(): bool
{
try {
return (bool)(new selfserve())->config->enabled->getVariableValue();
} catch (\Throwable) {
return false;
}
}
protected function canCustomerUseSelfServeLane(selfserve_lane $lane, int $customer_number): bool
{
return $customer_number > 0
@@ -1797,61 +1430,7 @@ class moduleSelfServeRoute
protected function canCustomerUsePropertyGateForLane(selfserve_lane $lane, int $customer_number): bool
{
return $this->canCustomerUseActiveOperationalSelfServeLane($lane, $customer_number, true);
}
protected function canCustomerUseActiveOperationalSelfServeLane(
selfserve_lane $lane,
int $customer_number,
bool $allow_department_active_wash = false
): bool {
return $this->isLaneSelfServeOperationallyEnabled($lane)
&& (
$allow_department_active_wash
? $this->canCustomerUseActiveSelfServeLane($lane, $customer_number)
: $this->canCustomerUseActiveSelfServeLaneSession($lane, $customer_number)
);
}
protected function canCustomerUseActiveSelfServeLaneSession(selfserve_lane $lane, int $customer_number): bool
{
if ($customer_number <= 0 || !$this->hasPermission(self::CUSTOMER_SELFSERVE_PERMISSION)) {
return false;
}
try {
if ((int)$lane->getCustomerNumber() === $customer_number) {
return true;
}
} catch (\Throwable) {
// Fall back to the persisted lane session lookup below.
}
$active_statuses = array_map(
static fn(selfserve_wash_session_status $status): string => $status->value,
[
selfserve_wash_session_status::MACHINE_RELAY_ENABLED,
selfserve_wash_session_status::READY_FOR_MACHINE_START,
selfserve_wash_session_status::MACHINE_STARTED,
selfserve_wash_session_status::PENDING_QUESTIONS,
selfserve_wash_session_status::MACHINE_NOT_ALLOWED,
]
);
$sessions = (new selfserve_wash_sessions_o())->getFieldsWhere([
'lane_id' => (int)$lane->id,
'customer_number' => $customer_number,
'completed_at' => null,
'deleted_at' => null,
], ['id', 'status']);
foreach ($sessions as $session) {
if (in_array((string)($session['status'] ?? ''), $active_statuses, true)) {
return true;
}
}
return false;
return $this->canCustomerUseActiveSelfServeLane($lane, $customer_number);
}
protected function isLaneSelfServeOperationallyEnabled(selfserve_lane $lane): bool
@@ -1936,6 +1515,7 @@ class moduleSelfServeRoute
return false;
}
private function requestedShellyTransportOverride(): ?string
{
$transport = null;
@@ -1973,37 +1553,9 @@ class moduleSelfServeRoute
}
self::requireMinValue($toggle_after, 1);
self::requireMaxValue($toggle_after, self::MAX_GATE_OPEN_TOGGLE_AFTER_SECONDS);
return $toggle_after;
}
private function machineStatusAuditUserName(?object $user): ?string
{
if (!$user) {
return null;
}
foreach (['display_name', 'email'] as $property) {
if (!isset($user->{$property}) || !is_object($user->{$property}) || !method_exists($user->{$property}, 'value')) {
continue;
}
$value = trim((string)$user->{$property}->value());
if ($value !== '' && strtolower($value) !== 'unnamed') {
return $value;
}
}
if (isset($user->customer_number) && is_object($user->customer_number) && method_exists($user->customer_number, 'value')) {
$customer_number = (int)$user->customer_number->value();
if ($customer_number > 0) {
return 'Kunde ' . $customer_number;
}
}
return isset($user->id) ? 'Bruger #' . (int)$user->id : null;
}
private function requestedBoolean(string $parameter, bool $default = false): bool
{
if (!self::isParametersSet([$parameter])) {
@@ -179,7 +179,7 @@ class moduleXLVaskRoute
$this->get('/modules/xlvask/tasks/sync-usage', function () {
global $response;
self::requirePermission('modules_xlvask_sync_usage');
//self::requirePermission('modules_xlvask_sync_usage');
// Remove the memory limit
// ini_set('memory_limit', '-1');
// Remove the execution time limit
@@ -3,7 +3,6 @@
namespace routes;
use classes\authentication;
use classes\email;
use classes\order_bookings_counts_cache;
use classes\order_bookings_list_cache;
use classes\redis;
@@ -369,29 +368,6 @@ class orderBookingRoute
]
);
$this->post('/order-bookings/booking-confirmation/resend', function () {
global $response;
$object = self::getTargetObject();
if (!$object || !$object->exists()) {
$response->error('Order booking does not exist.', 400);
}
self::requirePermission('resend_booking_confirmations');
self::requireDepartmentAccess((int)$object->department->value());
(new email())->sendOrderBookingConfirmationEmail($object);
$response->success([
'message' => 'Booking confirmation resent successfully.',
'booking' => $object->asArray(),
]);
},
[
'resend_booking_confirmations' => 'Permission for department admins to resend order booking confirmation emails.'
]
);
$this->post('/order-bookings/complete', function () {
// Require the user to be logged in
global $response;
@@ -752,14 +752,13 @@ class orderInvoicesRoute
['limit' => $limit, 'offset' => $offset] = $this->parseCollectedInvoiceQueuePagination();
$queue = new economic_transfer_queue();
$jobs = $queue->listJobsForCreatedBy(
$jobs = $queue->listJobs(
$statuses,
$limit,
$offset,
economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT,
(int)$user->id
economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT
);
$total_jobs = $this->countCollectedInvoiceQueueJobs($queue, $statuses, (int)$user->id);
$total_jobs = $this->countCollectedInvoiceQueueJobs($queue, $statuses);
$has_more = ($offset + count($jobs)) < $total_jobs;
$response->success([
@@ -786,7 +785,7 @@ class orderInvoicesRoute
$this->ensureEconomicTransferQueueIsAvailable();
$job_id = $this->requireCollectedInvoiceQueueJobId();
$job = $this->requireCollectedInvoiceQueueJobById($job_id, (int)$user->id);
$job = $this->requireCollectedInvoiceQueueJobById($job_id);
$response->success($this->withCollectedInvoiceQueueDetailsSummary($job));
},
@@ -828,14 +827,14 @@ class orderInvoicesRoute
$this->ensureEconomicTransferQueueIsAvailable();
$job_id = $this->requireCollectedInvoiceQueueJobId();
$job = $this->requireCollectedInvoiceQueueJobById($job_id, (int)$user->id, true);
$job = $this->requireCollectedInvoiceQueueJobById($job_id, true);
if ((int)($job['attempts'] ?? 0) >= (int)($job['max_attempts'] ?? 1)) {
$response->error('Collected invoice queue job reached max retry attempts', 409);
}
try {
$queue = new economic_transfer_queue();
$retried = $queue->retryJobForUser($job_id, (int)$user->id);
$retried = $queue->retryJob($job_id);
} catch (\Throwable $e) {
$message = trim((string)$e->getMessage());
$status_code = $this->resolveCollectedInvoiceQueueRetryErrorStatus($message);
@@ -862,7 +861,7 @@ class orderInvoicesRoute
$this->ensureEconomicTransferQueueIsAvailable();
$job_id = $this->requireCollectedInvoiceQueueJobId();
$job = $this->requireCollectedInvoiceQueueJobById($job_id, (int)$user->id);
$job = $this->requireCollectedInvoiceQueueJobById($job_id);
$status = strtoupper((string)($job['status'] ?? ''));
if (!in_array($status, [
economic_transfer_queue::STATUS_COMPLETED,
@@ -1817,7 +1816,6 @@ class orderInvoicesRoute
$warnings = [];
$invoice = (new collected_order_invoices_o())->select($collected_invoice_id);
$invoice->requireSelected();
$this->requireCollectedInvoiceContextAccess($invoice);
$draft_id = null;
$booked_id = null;
@@ -1937,56 +1935,6 @@ class orderInvoicesRoute
];
}
private function requireCollectedInvoiceContextAccess(collected_order_invoices_o $invoice): void
{
global $response;
if ($this->hasPermission('superuser')) {
return;
}
$invoice_customer_number = (int)$invoice->customer_number->value();
if ($invoice_customer_number > 0 && $this->isOwnCustomerContext($invoice_customer_number)) {
return;
}
if ($this->hasAccessToAllCollectedInvoiceDepartments((int)$invoice->id)) {
return;
}
$response->error('Permission denied for requested collected invoice.', 403);
}
private function hasAccessToAllCollectedInvoiceDepartments(int $collected_invoice_id): bool
{
$orders = new orders_o();
$order_departments = $orders->getFieldsWhere(
[
'invoice_collection_id' => $collected_invoice_id,
'deleted_at' => null,
],
['department_id']
);
$department_ids = array_values(array_unique(array_filter(array_map(static function (array $order): int {
return (int)($order['department_id'] ?? 0);
}, $order_departments), static function (int $department_id): bool {
return $department_id > 0;
})));
if (empty($department_ids)) {
return false;
}
foreach ($department_ids as $department_id) {
if (!$this->hasDepartmentAccess((string)$department_id)) {
return false;
}
}
return true;
}
private function extractEconomicCustomerNumber(mixed $invoice_raw): ?int
{
if ($invoice_raw === null) {
@@ -2205,12 +2153,12 @@ class orderInvoicesRoute
return (int)$job_id;
}
private function requireCollectedInvoiceQueueJobById(int $job_id, int $created_by, bool $mustBeFailed = false): array
private function requireCollectedInvoiceQueueJobById(int $job_id, bool $mustBeFailed = false): array
{
global $response;
$queue = new economic_transfer_queue();
$job = $queue->getJobByIdForUser($job_id, $created_by);
$job = $queue->getJobById($job_id);
if ($job === null || ($job['transfer_type'] ?? null) !== economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT) {
$response->error('Collected invoice queue job not found', 404);
}
@@ -2315,26 +2263,19 @@ class orderInvoicesRoute
];
}
private function countCollectedInvoiceQueueJobs(economic_transfer_queue $queue, array $statuses, int $created_by): int
private function countCollectedInvoiceQueueJobs(economic_transfer_queue $queue, array $statuses): int
{
global $db;
$created_by = max(0, $created_by);
if ($created_by < 1) {
return 0;
}
if (method_exists($queue, 'countJobsForCreatedBy')) {
return max(0, (int)$queue->countJobsForCreatedBy(
if (method_exists($queue, 'countJobs')) {
return max(0, (int)$queue->countJobs(
$statuses,
economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT,
$created_by
economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT
));
}
$conditions = [
"transfer_type = '" . $db->escape_string(economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT) . "'",
'created_by = ' . $created_by,
];
if ($statuses !== []) {
@@ -6,7 +6,6 @@ use classes\authentication;
use objects\logs_o;
use objects\order_items_o;
use objects\orders_o;
use objects\products_o;
use traits\route_t;
class orderItemsRoute
@@ -70,13 +69,6 @@ class orderItemsRoute
$price = (int)self::getParameter('price');
}
}
$product = (new products_o())->getProductById((int)$data['product_id']);
if (!$product->exists()) {
$response->error('Product not found', 404);
}
if ($product->requiresOrderItemNote() && trim((string)($notes ?? '')) === '') {
$response->error('Notes is required for this product', 400);
}
// Add the order item to the order This is done individually, to make the notes to the individual order items possible
$order_items = (new order_items_o());
@@ -211,26 +203,6 @@ class orderItemsRoute
if (!isset($data['quantity'])) {
$response->error('Quantity is required', 400);
}
$orderItem = (new order_items_o())->getOrderItemById((int)$data['id']);
if (!$orderItem->exists()) {
$response->error('Order item not found', 404);
}
$product = (new products_o())->getProductById((int)$orderItem->product_id->value());
if ($product->requiresOrderItemNote() && trim((string)$data['notes']) === '') {
$response->error('Notes is required for this product', 400);
}
$order = (new orders_o())->getOrderById((int)$orderItem->order_id->value());
if (!$order->exists()) {
$response->error('Order not found', 404);
}
$canAccessAllOrderItems = $this->hasPermission('list_order_items');
if (!$canAccessAllOrderItems && !$order->isOwnOrder((int)$user->customer_number->value())) {
$response->error('Order item does not belong to the user', 403);
}
// Update the order item
(new order_items_o())->updateOrderItem((int)$data['id'], (int)$data['price'], (string)$data['notes'], (string)$data['reference'], (int)$data['quantity']);
// Log the incident

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