From db9f589bf77c9a15408a6997c65e77c3e8f3c2df Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Tue, 4 Aug 2026 16:43:46 +0200 Subject: [PATCH] Resolve issue causing crash when plate_scanners.deleted_at was missing. (#346) Co-authored-by: Jeppe Bundgaard --- .../node_modules/.package-lock.json | 6 +-- .../node_modules/ws/lib/receiver.js | 37 +++++++++++++++++++ .../edge-broker/node_modules/ws/lib/sender.js | 7 +++- .../node_modules/ws/lib/websocket-server.js | 8 ++++ .../node_modules/ws/lib/websocket.js | 14 +++++++ .../edge-broker/node_modules/ws/package.json | 6 ++- .../nginx/app/routes/xlvaskUsageLogsRoute.php | 19 ++++++++-- .../Unit/XLVask/XLVaskUsageHallScopeTest.php | 27 +++++++++++++- 8 files changed, 114 insertions(+), 10 deletions(-) diff --git a/services/edge-broker/node_modules/.package-lock.json b/services/edge-broker/node_modules/.package-lock.json index ec33da94..6d75d5b5 100644 --- a/services/edge-broker/node_modules/.package-lock.json +++ b/services/edge-broker/node_modules/.package-lock.json @@ -4,9 +4,9 @@ "requires": true, "packages": { "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/services/edge-broker/node_modules/ws/lib/receiver.js b/services/edge-broker/node_modules/ws/lib/receiver.js index 54d9b4fa..784dbe1b 100644 --- a/services/edge-broker/node_modules/ws/lib/receiver.js +++ b/services/edge-broker/node_modules/ws/lib/receiver.js @@ -40,6 +40,10 @@ class Receiver extends Writable { * extensions * @param {Boolean} [options.isServer=false] Specifies whether to operate in * client or server mode + * @param {Number} [options.maxBufferedChunks=0] The maximum number of + * buffered data chunks + * @param {Number} [options.maxFragments=0] The maximum number of message + * fragments * @param {Number} [options.maxPayload=0] The maximum allowed message length * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or * not to skip UTF-8 validation for text and close messages @@ -54,6 +58,8 @@ class Receiver extends Writable { this._binaryType = options.binaryType || BINARY_TYPES[0]; this._extensions = options.extensions || {}; this._isServer = !!options.isServer; + this._maxBufferedChunks = options.maxBufferedChunks | 0; + this._maxFragments = options.maxFragments | 0; this._maxPayload = options.maxPayload | 0; this._skipUTF8Validation = !!options.skipUTF8Validation; this[kWebSocket] = undefined; @@ -71,6 +77,7 @@ class Receiver extends Writable { this._totalPayloadLength = 0; this._messageLength = 0; + this._numFragments = 0; this._fragments = []; this._errored = false; @@ -89,6 +96,22 @@ class Receiver extends Writable { _write(chunk, encoding, cb) { if (this._opcode === 0x08 && this._state == GET_INFO) return cb(); + if ( + this._maxBufferedChunks > 0 && + this._buffers.length >= this._maxBufferedChunks + ) { + cb( + this.createError( + RangeError, + 'Too many buffered chunks', + false, + 1008, + 'WS_ERR_TOO_MANY_BUFFERED_PARTS' + ) + ); + return; + } + this._bufferedBytes += chunk.length; this._buffers.push(chunk); this.startLoop(cb); @@ -478,6 +501,19 @@ class Receiver extends Writable { return; } + if (this._maxFragments > 0 && ++this._numFragments > this._maxFragments) { + const error = this.createError( + RangeError, + 'Too many message fragments', + false, + 1008, + 'WS_ERR_TOO_MANY_BUFFERED_PARTS' + ); + + cb(error); + return; + } + if (this._compressed) { this._state = INFLATING; this.decompress(data, cb); @@ -550,6 +586,7 @@ class Receiver extends Writable { this._totalPayloadLength = 0; this._messageLength = 0; this._fragmented = 0; + this._numFragments = 0; this._fragments = []; if (this._opcode === 2) { diff --git a/services/edge-broker/node_modules/ws/lib/sender.js b/services/edge-broker/node_modules/ws/lib/sender.js index a8b1da3a..2c767e2c 100644 --- a/services/edge-broker/node_modules/ws/lib/sender.js +++ b/services/edge-broker/node_modules/ws/lib/sender.js @@ -4,6 +4,9 @@ const { Duplex } = require('stream'); const { randomFillSync } = require('crypto'); +const { + types: { isUint8Array } +} = require('util'); const PerMessageDeflate = require('./permessage-deflate'); const { EMPTY_BUFFER, kWebSocket, NOOP } = require('./constants'); @@ -200,8 +203,10 @@ class Sender { if (typeof data === 'string') { buf.write(data, 2); - } else { + } else if (isUint8Array(data)) { buf.set(data, 2); + } else { + throw new TypeError('Second argument must be a string or a Uint8Array'); } } diff --git a/services/edge-broker/node_modules/ws/lib/websocket-server.js b/services/edge-broker/node_modules/ws/lib/websocket-server.js index 68aa7897..4d859192 100644 --- a/services/edge-broker/node_modules/ws/lib/websocket-server.js +++ b/services/edge-broker/node_modules/ws/lib/websocket-server.js @@ -43,6 +43,10 @@ class WebSocketServer extends EventEmitter { * called * @param {Function} [options.handleProtocols] A hook to handle protocols * @param {String} [options.host] The hostname where to bind the server + * @param {Number} [options.maxBufferedChunks=262144] The maximum number of + * buffered data chunks + * @param {Number} [options.maxFragments=16384] The maximum number of message + * fragments * @param {Number} [options.maxPayload=104857600] The maximum allowed message * size * @param {Boolean} [options.noServer=false] Enable no server mode @@ -65,6 +69,8 @@ class WebSocketServer extends EventEmitter { options = { allowSynchronousEvents: true, autoPong: true, + maxBufferedChunks: 256 * 1024, + maxFragments: 16 * 1024, maxPayload: 100 * 1024 * 1024, skipUTF8Validation: false, perMessageDeflate: false, @@ -424,6 +430,8 @@ class WebSocketServer extends EventEmitter { ws.setSocket(socket, head, { allowSynchronousEvents: this.options.allowSynchronousEvents, + maxBufferedChunks: this.options.maxBufferedChunks, + maxFragments: this.options.maxFragments, maxPayload: this.options.maxPayload, skipUTF8Validation: this.options.skipUTF8Validation }); diff --git a/services/edge-broker/node_modules/ws/lib/websocket.js b/services/edge-broker/node_modules/ws/lib/websocket.js index 75d5bb28..ea58e7df 100644 --- a/services/edge-broker/node_modules/ws/lib/websocket.js +++ b/services/edge-broker/node_modules/ws/lib/websocket.js @@ -201,6 +201,10 @@ class WebSocket extends EventEmitter { * multiple times in the same tick * @param {Function} [options.generateMask] The function used to generate the * masking key + * @param {Number} [options.maxBufferedChunks=0] The maximum number of + * buffered data chunks + * @param {Number} [options.maxFragments=0] The maximum number of message + * fragments * @param {Number} [options.maxPayload=0] The maximum allowed message size * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or * not to skip UTF-8 validation for text and close messages @@ -212,6 +216,8 @@ class WebSocket extends EventEmitter { binaryType: this.binaryType, extensions: this._extensions, isServer: this._isServer, + maxBufferedChunks: options.maxBufferedChunks, + maxFragments: options.maxFragments, maxPayload: options.maxPayload, skipUTF8Validation: options.skipUTF8Validation }); @@ -640,6 +646,10 @@ module.exports = WebSocket; * masking key * @param {Number} [options.handshakeTimeout] Timeout in milliseconds for the * handshake request + * @param {Number} [options.maxBufferedChunks=262144] The maximum number of + * buffered data chunks + * @param {Number} [options.maxFragments=16384] The maximum number of message + * fragments * @param {Number} [options.maxPayload=104857600] The maximum allowed message * size * @param {Number} [options.maxRedirects=10] The maximum number of redirects @@ -660,6 +670,8 @@ function initAsClient(websocket, address, protocols, options) { autoPong: true, closeTimeout: CLOSE_TIMEOUT, protocolVersion: protocolVersions[1], + maxBufferedChunks: 256 * 1024, + maxFragments: 16 * 1024, maxPayload: 100 * 1024 * 1024, skipUTF8Validation: false, perMessageDeflate: true, @@ -1017,6 +1029,8 @@ function initAsClient(websocket, address, protocols, options) { websocket.setSocket(socket, head, { allowSynchronousEvents: opts.allowSynchronousEvents, generateMask: opts.generateMask, + maxBufferedChunks: opts.maxBufferedChunks, + maxFragments: opts.maxFragments, maxPayload: opts.maxPayload, skipUTF8Validation: opts.skipUTF8Validation }); diff --git a/services/edge-broker/node_modules/ws/package.json b/services/edge-broker/node_modules/ws/package.json index 3618050a..ad5f9709 100644 --- a/services/edge-broker/node_modules/ws/package.json +++ b/services/edge-broker/node_modules/ws/package.json @@ -1,6 +1,6 @@ { "name": "ws", - "version": "8.20.0", + "version": "8.21.1", "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", "keywords": [ "HyBi", @@ -66,5 +66,9 @@ "nyc": "^15.0.0", "prettier": "^3.0.0", "utf-8-validate": "^6.0.0" + }, + "allowScripts": { + "bufferutil": true, + "utf-8-validate": true } } diff --git a/services/nginx/app/routes/xlvaskUsageLogsRoute.php b/services/nginx/app/routes/xlvaskUsageLogsRoute.php index 5e1eef58..90d7f210 100644 --- a/services/nginx/app/routes/xlvaskUsageLogsRoute.php +++ b/services/nginx/app/routes/xlvaskUsageLogsRoute.php @@ -627,10 +627,21 @@ class xlvaskUsageLogsRoute { global $db; if ($this->hasPermission('list_xlvask_usage_orders_all')) { - $result = $db->query( - "SELECT DISTINCT HallId FROM plate_scanners - WHERE HallId IS NOT NULL AND TRIM(HallId) <> '' AND deleted_at IS NULL" - ); + $sqlWithSoftDelete = "SELECT DISTINCT HallId FROM plate_scanners + WHERE HallId IS NOT NULL AND TRIM(HallId) <> '' AND deleted_at IS NULL"; + $sqlWithoutSoftDelete = "SELECT DISTINCT HallId FROM plate_scanners + WHERE HallId IS NOT NULL AND TRIM(HallId) <> ''"; + try { + $result = $db->query($sqlWithSoftDelete); + } catch (\Throwable $throwable) { + $message = (string)$throwable->getMessage(); + $missingDeletedAt = str_contains($message, "Unknown column 'deleted_at'") + || str_contains($message, "Unknown column `deleted_at`"); + if (!$missingDeletedAt) { + throw $throwable; + } + $result = $db->query($sqlWithoutSoftDelete); + } $hallIds = $result === false ? [] : array_map( static fn(array $row): string => trim((string)($row['HallId'] ?? '')), $db->fetch_all($result) diff --git a/services/nginx/app/tests/Unit/XLVask/XLVaskUsageHallScopeTest.php b/services/nginx/app/tests/Unit/XLVask/XLVaskUsageHallScopeTest.php index 2cdedc96..327d1626 100644 --- a/services/nginx/app/tests/Unit/XLVask/XLVaskUsageHallScopeTest.php +++ b/services/nginx/app/tests/Unit/XLVask/XLVaskUsageHallScopeTest.php @@ -26,15 +26,26 @@ class XLVaskUsageHallScopeRouteHarness extends \routes\xlvaskUsageLogsRoute class XLVaskUsageHallScopeDbFake { public int $queryCount = 0; + private bool $softDeleteQueryAttempted = false; /** @param array $rows */ - public function __construct(private readonly array $rows) + public function __construct( + private readonly array $rows, + private readonly bool $deletedAtColumnMissing = false + ) { } public function query(string $sql): object { $this->queryCount++; + if ($this->deletedAtColumnMissing + && !$this->softDeleteQueryAttempted + && str_contains($sql, 'deleted_at IS NULL')) { + $this->softDeleteQueryAttempted = true; + throw new \Exception("Unknown column 'deleted_at' in 'WHERE'"); + } + expect($sql)->toContain('SELECT DISTINCT HallId FROM plate_scanners'); return new stdClass(); @@ -116,6 +127,20 @@ it('uses every normalized scanner hall for users with all-scope permission', fun ->and($dbFake->queryCount)->toBe(1); }); +it('falls back when plate scanners table has no deleted_at column', function (): void { + $route = new XLVaskUsageHallScopeRouteHarness(); + $route->hasAllPermission = true; + $dbFake = new XLVaskUsageHallScopeDbFake([ + ['HallId' => ' Hall-A '], + ['HallId' => 'Hall-B'], + ], true); + + $hallIds = resolve_xlvask_usage_hall_scope($route, $dbFake, ['Own-Hall']); + + expect($hallIds)->toBe(['Hall-A', 'Hall-B']) + ->and($dbFake->queryCount)->toBe(2); +}); + it('keeps own-scope users limited to normalized group halls', function (): void { $route = new XLVaskUsageHallScopeRouteHarness(); $dbFake = new XLVaskUsageHallScopeDbFake([