From e6986bea09512dd50dd7b999913aceee0119a48e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20M=2E=20Pi=C3=B1eiro?= Date: Tue, 8 Jul 2025 23:10:28 +0200 Subject: [PATCH 1/4] Support for pre-compressed and ETag in download When downloading file request: 1. **Gzipped file serving**: - Automatically detects and serves pre-compressed `.gz` files when uncompressed originals are missing - Properly sets `Content-Encoding: gzip` headers - Implements `If-None-Match` header comparison for 304 (Not Modified) responses (RFC 7232) - Implements `ETag` header using CRC-32 from gzip trailer (bytes 4-7 from end) - Optimize for speed Changes affect: void AsyncWebServerRequest::send(FS &fs, const String &path, const char *contentType, bool download, AwsTemplateProcessor callback) AsyncWebServerResponse * AsyncWebServerRequest::beginResponse(FS &fs, const String &path, const char *contentType, bool download, AwsTemplateProcessor callback) AsyncFileResponse::AsyncFileResponse(FS &fs, const String &path, const char *contentType, bool download, AwsTemplateProcessor callback) : AsyncAbstractResponse(callback) --- src/AsyncWebServerRequest.cpp | 62 ++++++++++++++------------- src/WebResponses.cpp | 81 +++++++++++++++++++++-------------- 2 files changed, 81 insertions(+), 62 deletions(-) diff --git a/src/AsyncWebServerRequest.cpp b/src/AsyncWebServerRequest.cpp index 8a8d1bc9..2130cfd2 100644 --- a/src/AsyncWebServerRequest.cpp +++ b/src/AsyncWebServerRequest.cpp @@ -4,7 +4,7 @@ * @brief Sends a file from the filesystem to the client, with optional gzip compression and ETag-based caching. * * This method serves files over HTTP from the provided filesystem. If a compressed version of the file - * (with a `.gz` extension) exists and the `download` flag is not set, it serves the compressed file. + * (with a `.gz` extension) exists and uncompressed version do not exist, it serves the compressed file. * It also handles ETag caching using the CRC32 value from the gzip trailer, responding with `304 Not Modified` * if the client's `If-None-Match` header matches the generated ETag. * @@ -12,46 +12,50 @@ * @param path Path to the file to be served. * @param contentType Optional MIME type of the file to be sent. * If contentType is "" it will be obtained from the file extension - * @param download If true, forces the file to be sent as a download (disables gzip compression). + * @param download If true, forces the file to be sent as a download. * @param callback Optional template processor for dynamic content generation. * Templates will not be processed in compressed files. * * @note If neither the file nor its compressed version exists, responds with `404 Not Found`. */ void AsyncWebServerRequest::send(FS &fs, const String &path, const char *contentType, bool download, AwsTemplateProcessor callback) { - const String gzPath = path + asyncsrv::T__gz; - const bool useCompressedVersion = !download && fs.exists(gzPath); + // Check uncompressed file first + if (fs.exists(path)) { + send(beginResponse(fs, path, contentType, download, callback)); + return; + } - // If-None-Match header - if (useCompressedVersion && this->hasHeader(asyncsrv::T_INM)) { - // CRC32-based ETag of the trailer, bytes 4-7 from the end - File file = fs.open(gzPath, fs::FileOpenMode::read); - if (file && file.size() >= 18) { // 18 is the minimum size of valid gzip file - file.seek(file.size() - 8); + // Handle compressed version + const String gzPath = path + asyncsrv::T__gz; + File gzFile = fs.open(gzPath, "r"); - uint8_t crcFromGzipTrailer[4]; - if (file.read(crcFromGzipTrailer, sizeof(crcFromGzipTrailer)) == sizeof(crcFromGzipTrailer)) { - char serverETag[9]; - _getEtag(crcFromGzipTrailer, serverETag); + // Compressed file not found or invalid + if (!gzFile.seek(gzFile.size() - 8)) { + send(404); + gzFile.close(); + return; + } - // Compare with client's If-None-Match header - const AsyncWebHeader *inmHeader = this->getHeader(asyncsrv::T_INM); - if (inmHeader && inmHeader->value().equals(serverETag)) { - file.close(); - this->send(304); // Not Modified - return; - } - } - file.close(); + // ETag validation + if (this->hasHeader(asyncsrv::T_INM)) { + // Generate server ETag from CRC in gzip trailer + uint8_t crcInTrailer[4]; + gzFile.read(crcInTrailer, 4); + char serverETag[9]; + _getEtag(crcInTrailer, serverETag); + + // Compare with client's ETag + const AsyncWebHeader* inmHeader = this->getHeader(asyncsrv::T_INM); + if (inmHeader && inmHeader->value() == serverETag) { + gzFile.close(); + this->send(304); // Not Modified + return; } } - // If we get here, create and send the normal response - if (fs.exists(path) || useCompressedVersion) { - send(beginResponse(fs, path, contentType, download, callback)); - } else { - send(404); - } + // Send compressed file response + gzFile.close(); + send(beginResponse(fs, path, contentType, download, callback)); } /** diff --git a/src/WebResponses.cpp b/src/WebResponses.cpp index bc0f83c4..23bc4e83 100644 --- a/src/WebResponses.cpp +++ b/src/WebResponses.cpp @@ -670,38 +670,52 @@ void AsyncFileResponse::_setContentTypeFromPath(const String &path) { #endif } +/** + * @brief Constructor for AsyncFileResponse that handles file serving with compression support + * + * This constructor creates an AsyncFileResponse object that can serve files from a filesystem, + * with automatic fallback to gzip-compressed versions if the original file is not found. + * It also handles ETag generation for caching and supports both inline and download modes. + * + * @param fs Reference to the filesystem object used to open files + * @param path Path to the file to be served (without compression extension) + * @param contentType MIME type of the file content (empty string for auto-detection) + * @param download If true, file will be served as download attachment; if false, as inline content + * @param callback Template processor callback for dynamic content processing + */ AsyncFileResponse::AsyncFileResponse(FS &fs, const String &path, const char *contentType, bool download, AwsTemplateProcessor callback) : AsyncAbstractResponse(callback) { - _code = 200; - const String gzPath = path + asyncsrv::T__gz; - - if (!download && !fs.exists(path) && fs.exists(gzPath)) { - _path = gzPath; - _content = fs.open(gzPath, fs::FileOpenMode::read); + // Try to open the uncompressed version first + _content = fs.open(path, fs::FileOpenMode::read); + if (_content) { + _path = path; _contentLength = _content.size(); - addHeader(T_Content_Encoding, T_gzip, false); - _callback = nullptr; // Unable to process zipped templates - _sendContentLength = true; - _chunked = false; + } else { + // Try to open the compressed version (.gz) + _path = path + asyncsrv::T__gz; + _content = fs.open(_path, fs::FileOpenMode::read); + _contentLength = _content.size(); + + if (_content.seek(_contentLength - 8)) { + addHeader(T_Content_Encoding, T_gzip, false); + _callback = nullptr; // Unable to process zipped templates + _sendContentLength = true; + _chunked = false; - // CRC32-based ETag of the trailer, bytes 4-7 from the end - _content.seek(_contentLength - 8); - uint8_t crcInTrailer[4]; - if (_content.read(crcInTrailer, sizeof(crcInTrailer)) == sizeof(crcInTrailer)) { + // Add ETag and cache headers + uint8_t crcInTrailer[4]; + _content.read(crcInTrailer, sizeof(crcInTrailer)); char serverETag[9]; AsyncWebServerRequest::_getEtag(crcInTrailer, serverETag); - addHeader(T_ETag, serverETag, false); - addHeader(T_Cache_Control, T_no_cache, false); - } - - // Return to the beginning of the file - _content.seek(0); - } + addHeader(T_ETag, serverETag, true); + addHeader(T_Cache_Control, T_no_cache, true); - if (!_content) { - _path = path; - _content = fs.open(path, fs::FileOpenMode::read); - _contentLength = _content.size(); + _content.seek(0); + } else { + // File is corrupted or invalid + _code = 404; + return; + } } if (*contentType != '\0') { @@ -710,18 +724,19 @@ AsyncFileResponse::AsyncFileResponse(FS &fs, const String &path, const char *con _contentType = contentType; } - int filenameStart = path.lastIndexOf('/') + 1; - char buf[26 + path.length() - filenameStart]; - char *filename = (char *)path.c_str() + filenameStart; - if (download) { - // set filename and force download + // Extract filename from path and set as download attachment + int filenameStart = path.lastIndexOf('/') + 1; + char buf[26 + path.length() - filenameStart]; + char *filename = (char *)path.c_str() + filenameStart; snprintf_P(buf, sizeof(buf), PSTR("attachment; filename=\"%s\""), filename); + addHeader(T_Content_Disposition, buf, false); } else { - // set filename and force rendering - snprintf_P(buf, sizeof(buf), PSTR("inline")); + // Serve file inline (display in browser) + addHeader(T_Content_Disposition, PSTR("inline"), false); } - addHeader(T_Content_Disposition, buf, false); + + _code = 200; } AsyncFileResponse::AsyncFileResponse(File content, const String &path, const char *contentType, bool download, AwsTemplateProcessor callback) From f6bf206cdef042df0b6850f8e76d8306ba04a35e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Wed, 9 Jul 2025 16:10:12 +0000 Subject: [PATCH 2/4] ci(pre-commit): Apply automatic fixes --- src/AsyncWebServerRequest.cpp | 8 ++++---- src/WebResponses.cpp | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/AsyncWebServerRequest.cpp b/src/AsyncWebServerRequest.cpp index 2130cfd2..86ec02e9 100644 --- a/src/AsyncWebServerRequest.cpp +++ b/src/AsyncWebServerRequest.cpp @@ -26,8 +26,8 @@ void AsyncWebServerRequest::send(FS &fs, const String &path, const char *content } // Handle compressed version - const String gzPath = path + asyncsrv::T__gz; - File gzFile = fs.open(gzPath, "r"); + const String gzPath = path + asyncsrv::T__gz; + File gzFile = fs.open(gzPath, "r"); // Compressed file not found or invalid if (!gzFile.seek(gzFile.size() - 8)) { @@ -43,9 +43,9 @@ void AsyncWebServerRequest::send(FS &fs, const String &path, const char *content gzFile.read(crcInTrailer, 4); char serverETag[9]; _getEtag(crcInTrailer, serverETag); - + // Compare with client's ETag - const AsyncWebHeader* inmHeader = this->getHeader(asyncsrv::T_INM); + const AsyncWebHeader *inmHeader = this->getHeader(asyncsrv::T_INM); if (inmHeader && inmHeader->value() == serverETag) { gzFile.close(); this->send(304); // Not Modified diff --git a/src/WebResponses.cpp b/src/WebResponses.cpp index 23bc4e83..8519918a 100644 --- a/src/WebResponses.cpp +++ b/src/WebResponses.cpp @@ -672,11 +672,11 @@ void AsyncFileResponse::_setContentTypeFromPath(const String &path) { /** * @brief Constructor for AsyncFileResponse that handles file serving with compression support - * + * * This constructor creates an AsyncFileResponse object that can serve files from a filesystem, * with automatic fallback to gzip-compressed versions if the original file is not found. * It also handles ETag generation for caching and supports both inline and download modes. - * + * * @param fs Reference to the filesystem object used to open files * @param path Path to the file to be served (without compression extension) * @param contentType MIME type of the file content (empty string for auto-detection) From a2c962560498dc4fda786f1ebc3ddfae4ef4b509 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20M=2E=20Pi=C3=B1eiro?= Date: Wed, 9 Jul 2025 18:38:01 +0200 Subject: [PATCH 3/4] Update src/AsyncWebServerRequest.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/AsyncWebServerRequest.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AsyncWebServerRequest.cpp b/src/AsyncWebServerRequest.cpp index 86ec02e9..fbe662f7 100644 --- a/src/AsyncWebServerRequest.cpp +++ b/src/AsyncWebServerRequest.cpp @@ -4,7 +4,7 @@ * @brief Sends a file from the filesystem to the client, with optional gzip compression and ETag-based caching. * * This method serves files over HTTP from the provided filesystem. If a compressed version of the file - * (with a `.gz` extension) exists and uncompressed version do not exist, it serves the compressed file. + * (with a `.gz` extension) exists and uncompressed version does not exist, it serves the compressed file. * It also handles ETag caching using the CRC32 value from the gzip trailer, responding with `304 Not Modified` * if the client's `If-None-Match` header matches the generated ETag. * From 91ea150db86e4b065d7599c9d38d1c36e71d799f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20M=2E=20Pi=C3=B1eiro?= Date: Wed, 9 Jul 2025 21:34:27 +0200 Subject: [PATCH 4/4] Add files via upload --- src/WebResponses.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WebResponses.cpp b/src/WebResponses.cpp index 8519918a..21b15c1d 100644 --- a/src/WebResponses.cpp +++ b/src/WebResponses.cpp @@ -687,7 +687,7 @@ AsyncFileResponse::AsyncFileResponse(FS &fs, const String &path, const char *con : AsyncAbstractResponse(callback) { // Try to open the uncompressed version first _content = fs.open(path, fs::FileOpenMode::read); - if (_content) { + if (_content.available()) { _path = path; _contentLength = _content.size(); } else {