diff --git a/interface/src/components/upload/SingleUpload.tsx b/interface/src/components/upload/SingleUpload.tsx index 3f698b29e..134cb9918 100644 --- a/interface/src/components/upload/SingleUpload.tsx +++ b/interface/src/components/upload/SingleUpload.tsx @@ -29,11 +29,22 @@ const SingleUpload = ({ doRestart }: SingleUploadProps) => { } = useRequest(SystemApi.uploadFile, { immediate: false }).onSuccess(({ data }) => { - if (data && typeof data === 'object' && 'md5' in data) { - setMd5((data as { md5: string }).md5); - toast.success(LL.UPLOAD() + ' MD5 ' + LL.SUCCESSFUL()); + let payload = data; + if (typeof payload === 'string' && payload.length > 0) { + try { + payload = JSON.parse(payload); + } catch { + payload = data; + } + } + if (payload && typeof payload === 'object' && 'md5' in payload) { + setMd5((payload as { md5: string }).md5); + toast.success(LL.UPLOAD_MD5_RECEIVED()); setFile(undefined); } else { + if (payload && typeof payload === 'object' && 'md5_ok' in payload) { + toast.success(LL.UPLOAD_MD5_MATCHED()); + } doRestart(); } }); @@ -80,12 +91,17 @@ const SingleUpload = ({ doRestart }: SingleUploadProps) => { ) : ( - + )} {md5 && ( - {'MD5: ' + md5} + + {'MD5: ' + md5} + )} diff --git a/mock-api/mockSSEServer.js b/mock-api/mockSSEServer.js new file mode 100644 index 000000000..914164d8d --- /dev/null +++ b/mock-api/mockSSEServer.js @@ -0,0 +1,106 @@ +// Simulate EventSource for log messages in EMS-ESP standalone mode +// Server-Sent Events (SSE) +const ONE_SECOND_MS = 1000; + +// padding function +const pad = (number) => String(number).padStart(2, '0'); + +// Cached date formatter to avoid prototype pollution +const formatDate = (date) => { + const year = date.getUTCFullYear(); + const month = pad(date.getUTCMonth() + 1); + const day = pad(date.getUTCDate()); + const hours = pad(date.getUTCHours()); + const minutes = pad(date.getUTCMinutes()); + const seconds = pad(date.getUTCSeconds()); + const milliseconds = String((date.getUTCMilliseconds() / 1000).toFixed(3)).slice( + 2, + 5 + ); + + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`; +}; + +export default () => { + return { + name: 'vite:mockserver', + configureServer: async (server) => { + server.middlewares.use(async (req, res, next) => { + // Handle Server-Sent Events (SSE) for log streaming + if (req.url.startsWith('/es/log')) { + // Set SSE headers + res.writeHead(200, { + Connection: 'keep-alive', + 'Cache-Control': 'no-cache, no-transform', + 'Content-Type': 'text/event-stream', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Cache-Control', + 'X-Accel-Buffering': 'no' // disable proxy buffering (nginx, etc.) + }); + + // Flush headers early when supported + if (typeof res.flushHeaders === 'function') { + res.flushHeaders(); + } + + let messageCount = 0; + const logLevels = [3, 4, 5, 6, 7, 8]; // Different log levels + const logNames = ['system', 'ems', 'wifi', 'mqtt', 'ntp', 'api']; + + const sendLogMessage = () => { + const level = logLevels[messageCount % logLevels.length]; + const name = logNames[messageCount % logNames.length]; + let message = `Log message #${messageCount}`; + + // Add long message every 6th message + if (messageCount % 6 === 1) { + message += + ' - This is a longer message to test text wrapping and truncation behavior in the UI'; + } + + const logData = { + t: formatDate(new Date()), + l: level, + i: messageCount, + n: name, + m: message + }; + + res.write(`data: ${JSON.stringify(logData)}\n\n`); + messageCount++; + }; + + // Send initial message + res.write(`retry: 2000\n\n`); // client reconnection delay + sendLogMessage(); + + // Set up interval for periodic messages + const messageInterval = setInterval(sendLogMessage, 500); + if (typeof messageInterval.unref === 'function') messageInterval.unref(); + + // Heartbeat to keep connections alive through proxies + const heartbeat = setInterval(() => { + res.write(`:keep-alive ${Date.now()}\n\n`); + }, 15 * ONE_SECOND_MS); + if (typeof heartbeat.unref === 'function') heartbeat.unref(); + + // Clean up on connection close + const cleanup = () => { + console.log('SSE connection closed'); + clearInterval(messageInterval); + clearInterval(heartbeat); + if (!res.destroyed) { + res.end(); + } + }; + + res.on('close', cleanup); + res.on('error', cleanup); + res.on('finish', cleanup); + } else { + next(); // Continue to next middleware (Vite proxy → restServer) + } + }); + } + }; +}; diff --git a/mock-api/mockServer.js b/mock-api/mockServer.js deleted file mode 100644 index 2987ac012..000000000 --- a/mock-api/mockServer.js +++ /dev/null @@ -1,218 +0,0 @@ -// Mock server for development -// Simulates file uploads and EventSource (SSE) for log messages -import formidable from 'formidable'; - -// Constants reused across requests -const VALID_EXTENSIONS = new Set(['bin', 'json', 'md5']); -const ONE_SECOND_MS = 1000; -const TEN_PERCENT = 10; - -// padding function -const pad = (number) => String(number).padStart(2, '0'); - -// Simple throttle helper (time-based) -const throttle = (fn, intervalMs) => { - let last = 0; - return (...args) => { - const now = Date.now(); - if (now - last >= intervalMs) { - last = now; - fn(...args); - } - }; -}; - -// Cached date formatter to avoid prototype pollution -const formatDate = (date) => { - const year = date.getUTCFullYear(); - const month = pad(date.getUTCMonth() + 1); - const day = pad(date.getUTCDate()); - const hours = pad(date.getUTCHours()); - const minutes = pad(date.getUTCMinutes()); - const seconds = pad(date.getUTCSeconds()); - const milliseconds = String((date.getUTCMilliseconds() / 1000).toFixed(3)).slice( - 2, - 5 - ); - - return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`; -}; - -export default () => { - return { - name: 'vite:mockserver', - configureServer: async (server) => { - server.middlewares.use(async (req, res, next) => { - // Handle file uploads - if (req.url.startsWith('/rest/uploadFile')) { - // CORS preflight support - if (req.method === 'OPTIONS') { - res.writeHead(204, { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Cache-Control', - 'Access-Control-Max-Age': '600' - }); - res.end(); - return; - } - - if (req.method !== 'POST') { - res.statusCode = 405; - res.setHeader('Allow', 'POST, OPTIONS'); - res.end('Method Not Allowed'); - return; - } - - const fileSize = parseInt(req.headers['content-length'] || '0', 10); - let progress = 0; - - // Track upload progress - const logThrottled = throttle((percentage) => { - console.log(`Upload progress: ${percentage}%`); - }, ONE_SECOND_MS); - - req.on('data', (chunk) => { - progress += chunk.length; - if (fileSize > 0) { - const percentage = Math.round((progress / fileSize) * 100); - // Only log every ~1s and for meaningful changes (>=10%) - if (percentage % TEN_PERCENT === 0) { - logThrottled(percentage); - } - } - }); - - try { - const form = formidable({ - maxFileSize: 50 * 1024 * 1024, // 50MB limit - keepExtensions: true, - multiples: false, - allowEmptyFiles: false - }); - - const [fields, files] = await form.parse(req); - - if (Object.keys(files).length === 0) { - res.statusCode = 400; - res.end('No file uploaded'); - return; - } - - const uploadedFile = Array.isArray(files.file) - ? files.file[0] - : files.file; - const fileName = uploadedFile.originalFilename; - const fileExtension = fileName - .substring(fileName.lastIndexOf('.') + 1) - .toLowerCase(); - - console.log( - `File uploaded: ${fileName} (${fileExtension}, ${fileSize} bytes)` - ); - - // Validate file extension - if (!VALID_EXTENSIONS.has(fileExtension)) { - res.statusCode = 406; - res.end('Invalid file extension'); - return; - } - - // Handle different file types - if (fileExtension === 'md5') { - res.setHeader('Content-Type', 'application/json'); - res.end( - JSON.stringify({ - md5: 'ef4304fc4d9025a58dcf25d71c882d2c' - }) - ); - } else { - console.log('File uploaded successfully!'); - res.end(); - } - } catch (err) { - console.error('Upload error:', err && err.message ? err.message : err); - res.statusCode = err.httpCode || 400; - res.setHeader('Content-Type', 'text/plain'); - res.end(err && err.message ? err.message : 'Upload error'); - } - } - - // Handle Server-Sent Events (SSE) for log streaming - else if (req.url.startsWith('/es/log')) { - // Set SSE headers - res.writeHead(200, { - Connection: 'keep-alive', - 'Cache-Control': 'no-cache, no-transform', - 'Content-Type': 'text/event-stream', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Cache-Control', - 'X-Accel-Buffering': 'no' // disable proxy buffering (nginx, etc.) - }); - - // Flush headers early when supported - if (typeof res.flushHeaders === 'function') { - res.flushHeaders(); - } - - let messageCount = 0; - const logLevels = [3, 4, 5, 6, 7, 8]; // Different log levels - const logNames = ['system', 'ems', 'wifi', 'mqtt', 'ntp', 'api']; - - const sendLogMessage = () => { - const level = logLevels[messageCount % logLevels.length]; - const name = logNames[messageCount % logNames.length]; - let message = `Log message #${messageCount}`; - - // Add long message every 6th message - if (messageCount % 6 === 1) { - message += - ' - This is a longer message to test text wrapping and truncation behavior in the UI'; - } - - const logData = { - t: formatDate(new Date()), - l: level, - i: messageCount, - n: name, - m: message - }; - - res.write(`data: ${JSON.stringify(logData)}\n\n`); - messageCount++; - }; - - // Send initial message - res.write(`retry: 2000\n\n`); // client reconnection delay - sendLogMessage(); - - // Set up interval for periodic messages - const messageInterval = setInterval(sendLogMessage, 500); - if (typeof messageInterval.unref === 'function') messageInterval.unref(); - - // Heartbeat to keep connections alive through proxies - const heartbeat = setInterval(() => { - res.write(`:keep-alive ${Date.now()}\n\n`); - }, 15 * ONE_SECOND_MS); - if (typeof heartbeat.unref === 'function') heartbeat.unref(); - - // Clean up on connection close - const cleanup = () => { - console.log('SSE connection closed'); - clearInterval(messageInterval); - clearInterval(heartbeat); - if (!res.destroyed) { - res.end(); - } - }; - - res.on('close', cleanup); - res.on('error', cleanup); - res.on('finish', cleanup); - } else { - next(); // Continue to next middleware - } - }); - } - }; -}; diff --git a/mock-api/restServer.ts b/mock-api/restServer.ts index de1fce771..a2ba1e60f 100644 --- a/mock-api/restServer.ts +++ b/mock-api/restServer.ts @@ -239,6 +239,65 @@ switch (emulate_esp) { // GLOBAL VARIABLES let countWifiScanPoll = 0; // wifi network scan let countHardwarePoll = 0; // for during an upload +let pendingFirmwareMd5 = false; // set by a .md5 upload, cleared by the next .bin + +const VALID_UPLOAD_EXTENSIONS = new Set(['bin', 'json', 'md5']); +const UPLOAD_PROGRESS_LOG_STEP = 10; // log every 10% + +// A real device takes a while to accept a firmware image, so uploads are read at +// this rate instead of completing instantly. That keeps the WebUI progress bar on +// screen long enough to see. Lower it to watch the progress bar for longer. +const UPLOAD_SIMULATED_KB_PER_SEC = 2048; +const UPLOAD_SLICE_BYTES = 64 * 1024; // granularity of the simulated transfer + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +function parseMd5Digest(text: string): string | null { + const token = text.trim().split(/\s+/)[0]; + if (/^[0-9a-fA-F]{32}$/.test(token)) { + return token.toLowerCase(); + } + return null; +} + +// Read the multipart body in slices at UPLOAD_SIMULATED_KB_PER_SEC, logging progress +// as it goes, then hand the buffered bytes back as FormData so they parse normally. +async function readUploadSlowly(request: Request): Promise { + const contentType = request.headers.get('content-type') || ''; + const expected = Number(request.headers.get('content-length') || '0'); + const reader = request.body?.getReader(); + const chunks: Uint8Array[] = []; + let received = 0; + let loggedPercentage = 0; + + while (reader) { + const { done, value } = await reader.read(); + if (done) { + break; + } + chunks.push(value); + + // A fast local client arrives in very few chunks, so pace each one slice by + // slice to keep the reported progress gradual instead of jumping to 100% + for (let offset = 0; offset < value.length; offset += UPLOAD_SLICE_BYTES) { + const sliceLength = Math.min(UPLOAD_SLICE_BYTES, value.length - offset); + await sleep((sliceLength / (UPLOAD_SIMULATED_KB_PER_SEC * 1024)) * 1000); + received += sliceLength; + + if (expected > 0) { + const percentage = Math.round((received / expected) * 100); + if (percentage >= loggedPercentage + UPLOAD_PROGRESS_LOG_STEP) { + loggedPercentage = percentage; + console.log(`Upload progress: ${percentage}%`); + } + } + } + } + + return new Response(new Blob(chunks as BlobPart[]), { + headers: { 'content-type': contentType } + }).formData(); +} // DeviceTypes const enum ScheduleFlag { @@ -434,16 +493,20 @@ function upgradeImportantMessages(version: string) { // 0 is do nothing // 1 means 3.9 and factory reset required // 2 means a major version upgrade + let upgradeImportantMessageType_n = 0; - // see if its a filename with a .bin extension - if (version.endsWith('.bin')) { - upgradeImportantMessageType_n = 1; // make it 1, for testing, meaning factory reset required - } else if (version.endsWith('.md')) { - upgradeImportantMessageType_n = 0; // use default 0, no message + // check file extensions + if (version.endsWith('.md5') || version.endsWith('.json')) { + upgradeImportantMessageType_n = 0; // digest / backup restore: no upgrade warning + } else if (version.endsWith('.bin')) { + // extract the version number from the filename and if its going from 3.8.x to 3.9.x, then set upgradeImportantMessageType_n to 1 + const versionNumber = version.split('.'); + if (versionNumber[0] === '3' && versionNumber[1] === '8') { + upgradeImportantMessageType_n = 1; // make it 1, factory reset required + } } else { // this is a version string like "3.9.0" - // upgradeImportantMessageType_n = 2; // make it 2, for testing, meaning a major version upgrade upgradeImportantMessageType_n = 1; // make it 1, for testing, meaning a factory reset is required } @@ -774,6 +837,7 @@ const EMSESP_COMMANDS_ENDPOINT = REST_ENDPOINT_ROOT + 'commands'; const EMSESP_CUSTOMENTITIES_ENDPOINT = REST_ENDPOINT_ROOT + 'customEntities'; const EMSESP_MODULES_ENDPOINT = REST_ENDPOINT_ROOT + 'modules'; const EMSESP_ACTION_ENDPOINT = REST_ENDPOINT_ROOT + 'action'; +const EMSESP_UPLOAD_FILE_ENDPOINT = REST_ENDPOINT_ROOT + 'uploadFile'; // these are used in the API calls only const EMSESP_SYSTEM_INFO_ENDPOINT = API_ENDPOINT_ROOT + 'system/info'; @@ -4637,6 +4701,47 @@ router // SYSTEM and SETTINGS router + .post(EMSESP_UPLOAD_FILE_ENDPOINT, async (request: Request) => { + const formData = await readUploadSlowly(request); + const uploaded = formData.get('file'); + if (!uploaded || typeof uploaded === 'string') { + return status(400); + } + + const fileName = uploaded.name || ''; + const fileExtension = fileName.includes('.') + ? fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase() + : ''; + console.log( + `File uploaded: ${fileName} (${fileExtension}, ${uploaded.size} bytes)` + ); + + if (!VALID_UPLOAD_EXTENSIONS.has(fileExtension)) { + console.log('Invalid file extension'); + return status(406); + } + + if (fileExtension === 'md5') { + const digest = parseMd5Digest(await uploaded.text()); + if (!digest) { + console.log('Invalid MD5 digest file'); + return status(406); + } + pendingFirmwareMd5 = true; + console.log('MD5 digest received', digest); + return { md5: digest }; + } + + if (fileExtension === 'bin' && pendingFirmwareMd5) { + pendingFirmwareMd5 = false; + console.log('Firmware MD5 matches'); + return { md5_ok: true }; + } + + pendingFirmwareMd5 = false; + console.log('File uploaded successfully!'); + return status(200); + }) .get(ACTIVITY_ENDPOINT, () => activity) .get(SYSTEM_STATUS_ENDPOINT, async () => { if (countHardwarePoll >= 2) { diff --git a/project-words.txt b/project-words.txt index 2aa5d4e27..3204475a4 100644 --- a/project-words.txt +++ b/project-words.txt @@ -744,7 +744,7 @@ mlongcalls mmio mmode mmplus -mockserver +mockSSEServer modbus modee modetype diff --git a/src/ESP32React/UploadFileService.cpp b/src/ESP32React/UploadFileService.cpp index 240701ef2..a10266192 100644 --- a/src/ESP32React/UploadFileService.cpp +++ b/src/ESP32React/UploadFileService.cpp @@ -13,10 +13,32 @@ static String getFilenameExtension(const String & filename) { return {}; } +// Accepts a raw 32-char hex digest, optionally with trailing newline or a GNU "hash filename" suffix. +static bool parseMd5Digest(const uint8_t * data, size_t len, std::array & out) { + size_t i = 0; + while (i < len && (data[i] == ' ' || data[i] == '\t' || data[i] == '\r' || data[i] == '\n')) { + ++i; + } + if (len - i < 32) { + return false; + } + for (size_t n = 0; n < 32; n++) { + const char c = static_cast(data[i + n]); + const bool hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + if (!hex) { + return false; + } + out[n] = (c >= 'A' && c <= 'F') ? static_cast(c - 'A' + 'a') : c; + } + out[32] = '\0'; + return true; +} + UploadFileService::UploadFileService(AsyncWebServer * server, SecurityManager * securityManager) : _securityManager(securityManager) , _is_firmware(false) , _is_filesystem(false) + , _md5_applied(false) , _md5() { server->on( UPLOAD_FILE_PATH, @@ -48,18 +70,23 @@ void UploadFileService::handleUpload(AsyncWebServerRequest * request, const Stri // LittleFS filesystem image _is_filesystem = true; _md5[0] = '\0'; // clear any stale md5 so Update.end() doesn't compare against it + _md5_applied = false; } else if ((extension == "bin") && (filesize > 1000000)) { _is_firmware = true; } else if (extension == "json") { - _md5[0] = '\0'; // clear md5 + _md5[0] = '\0'; // clear md5 + _md5_applied = false; } else if (extension == "md5") { - if (len == _md5.size() - 1) { - std::memcpy(_md5.data(), data, _md5.size() - 1); - _md5.back() = '\0'; + if (!parseMd5Digest(data, len, _md5)) { + emsesp::EMSESP::logger().err("Invalid MD5 digest file"); + handleError(request, 406); // Not Acceptable + } else { + emsesp::EMSESP::logger().info("MD5 digest received (%s). Now upload the firmware BIN", _md5.data()); } return; } else { _md5.front() = '\0'; + _md5_applied = false; emsesp::EMSESP::logger().err("Unsupported file type: %s, size: %u", filename.c_str(), filesize); handleError(request, 406); // Not Acceptable - unsupported file type return; @@ -103,9 +130,11 @@ void UploadFileService::handleUpload(AsyncWebServerRequest * request, const Stri emsesp::EMSESP::system_.systemStatus(emsesp::SYSTEM_STATUS::SYSTEM_STATUS_UPLOADING); if (Update.begin(filesize - sizeof(esp_image_header_t))) { + _md5_applied = false; if (strlen(_md5.data()) == _md5.size() - 1) { Update.setMD5(_md5.data()); - _md5.front() = '\0'; + _md5_applied = true; + emsesp::EMSESP::logger().info("Firmware MD5 check enabled (%s)", _md5.data()); } request->onDisconnect([this] { handleDisconnect(); }); // success, let's make sure we end the update if the client hangs up } else { @@ -183,8 +212,19 @@ void UploadFileService::uploadComplete(AsyncWebServerRequest * request) { emsesp::EMSESP::nvs_.putBool(emsesp::EMSESP_NVS_BOOT_NEW_FIRMWARE, true); } - AsyncWebServerResponse * response = request->beginResponse(200); - request->send(response); + if (_is_firmware && _md5_applied) { + emsesp::EMSESP::logger().info("Firmware MD5 matches"); + auto * response = new emsesp::PsramAsyncJsonResponse(false); + JsonObject root = response->getRoot(); + root["md5_ok"] = true; + response->setLength(); + request->send(response); + _md5.front() = '\0'; + _md5_applied = false; + } else { + AsyncWebServerResponse * response = request->beginResponse(200); + request->send(response); + } emsesp::EMSESP::system_.systemStatus( emsesp::SYSTEM_STATUS::SYSTEM_STATUS_PENDING_RESTART); // will be handled by the main loop. We use pending for the Web's SystemMonitor return; diff --git a/src/ESP32React/UploadFileService.h b/src/ESP32React/UploadFileService.h index 4c8a20dbb..9da611c7a 100644 --- a/src/ESP32React/UploadFileService.h +++ b/src/ESP32React/UploadFileService.h @@ -23,6 +23,7 @@ class UploadFileService { SecurityManager * _securityManager; bool _is_firmware; bool _is_filesystem; + bool _md5_applied; // true if an MD5 digest was applied to the current firmware upload std::array _md5; void handleUpload(AsyncWebServerRequest * request, const String & filename, size_t index, uint8_t * data, size_t len, bool final);