Skip to content

Commit 2803989

Browse files
ETag and caching for serving pre‐compressed files (#216)
* ETag and caching for serving pre‐compressed files This PR introduces ETag handling and caching improvements for serving pre‐compressed .gz files, enabling conditional GET responses to optimize bandwidth and server resource usage. Adds ETag header generation based on the CRC32 checksum from the gzip trailer. Implements conditional 304 responses in the send() method when the client's ETag matches the server's. Enhances both asynchronous file response handling and web server request processing for .gz files. * Add files via upload * ci(pre-commit): Apply automatic fixes * Make _getEtag a private method Make private: static void _getEtag(uint8_t trailer[4], char *serverETag) --------- Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
1 parent 4b14b0a commit 2803989

File tree

3 files changed

+109
-13
lines changed

3 files changed

+109
-13
lines changed

src/AsyncWebServerRequest.cpp

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
#include <ESPAsyncWebServer.h>
2+
3+
/**
4+
* @brief Sends a file from the filesystem to the client, with optional gzip compression and ETag-based caching.
5+
*
6+
* This method serves files over HTTP from the provided filesystem. If a compressed version of the file
7+
* (with a `.gz` extension) exists and the `download` flag is not set, it serves the compressed file.
8+
* It also handles ETag caching using the CRC32 value from the gzip trailer, responding with `304 Not Modified`
9+
* if the client's `If-None-Match` header matches the generated ETag.
10+
*
11+
* @param fs Reference to the filesystem (SPIFFS, LittleFS, etc.).
12+
* @param path Path to the file to be served.
13+
* @param contentType Optional MIME type of the file to be sent.
14+
* If contentType is "" it will be obtained from the file extension
15+
* @param download If true, forces the file to be sent as a download (disables gzip compression).
16+
* @param callback Optional template processor for dynamic content generation.
17+
* Templates will not be processed in compressed files.
18+
*
19+
* @note If neither the file nor its compressed version exists, responds with `404 Not Found`.
20+
*/
21+
void AsyncWebServerRequest::send(FS &fs, const String &path, const char *contentType, bool download, AwsTemplateProcessor callback) {
22+
const String gzPath = path + asyncsrv::T__gz;
23+
const bool useCompressedVersion = !download && fs.exists(gzPath);
24+
25+
// If-None-Match header
26+
if (useCompressedVersion && this->hasHeader(asyncsrv::T_INM)) {
27+
// CRC32-based ETag of the trailer, bytes 4-7 from the end
28+
File file = fs.open(gzPath, fs::FileOpenMode::read);
29+
if (file && file.size() >= 18) { // 18 is the minimum size of valid gzip file
30+
file.seek(file.size() - 8);
31+
32+
uint8_t crcFromGzipTrailer[4];
33+
if (file.read(crcFromGzipTrailer, sizeof(crcFromGzipTrailer)) == sizeof(crcFromGzipTrailer)) {
34+
char serverETag[9];
35+
_getEtag(crcFromGzipTrailer, serverETag);
36+
37+
// Compare with client's If-None-Match header
38+
const AsyncWebHeader *inmHeader = this->getHeader(asyncsrv::T_INM);
39+
if (inmHeader && inmHeader->value().equals(serverETag)) {
40+
file.close();
41+
this->send(304); // Not Modified
42+
return;
43+
}
44+
}
45+
file.close();
46+
}
47+
}
48+
49+
// If we get here, create and send the normal response
50+
if (fs.exists(path) || useCompressedVersion) {
51+
send(beginResponse(fs, path, contentType, download, callback));
52+
} else {
53+
send(404);
54+
}
55+
}
56+
57+
/**
58+
* @brief Generates an ETag string from a 4-byte trailer
59+
*
60+
* This function converts a 4-byte array into a hexadecimal ETag string enclosed in quotes.
61+
*
62+
* @param trailer[4] Input array of 4 bytes to convert to hexadecimal
63+
* @param serverETag Output buffer to store the ETag
64+
* Must be pre-allocated with minimum 9 bytes (8 hex + 1 null terminator)
65+
*/
66+
void AsyncWebServerRequest::_getEtag(uint8_t trailer[4], char *serverETag) {
67+
static constexpr char hexChars[] = "0123456789ABCDEF";
68+
69+
uint32_t data;
70+
memcpy(&data, trailer, 4);
71+
72+
serverETag[0] = hexChars[(data >> 4) & 0x0F];
73+
serverETag[1] = hexChars[data & 0x0F];
74+
serverETag[2] = hexChars[(data >> 12) & 0x0F];
75+
serverETag[3] = hexChars[(data >> 8) & 0x0F];
76+
serverETag[4] = hexChars[(data >> 20) & 0x0F];
77+
serverETag[5] = hexChars[(data >> 16) & 0x0F];
78+
serverETag[6] = hexChars[(data >> 28)];
79+
serverETag[7] = hexChars[(data >> 24) & 0x0F];
80+
serverETag[8] = '\0';
81+
}

src/ESPAsyncWebServer.h

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ class AsyncWebServerRequest {
203203
using FS = fs::FS;
204204
friend class AsyncWebServer;
205205
friend class AsyncCallbackWebHandler;
206+
friend class AsyncFileResponse;
206207

207208
private:
208209
AsyncClient *_client;
@@ -274,6 +275,8 @@ class AsyncWebServerRequest {
274275
void _send();
275276
void _runMiddlewareChain();
276277

278+
static void _getEtag(uint8_t trailer[4], char *serverETag);
279+
277280
public:
278281
File _tempFile;
279282
void *_tempObject;
@@ -386,13 +389,7 @@ class AsyncWebServerRequest {
386389
send(beginResponse(code, contentType, content, len, callback));
387390
}
388391

389-
void send(FS &fs, const String &path, const char *contentType = asyncsrv::empty, bool download = false, AwsTemplateProcessor callback = nullptr) {
390-
if (fs.exists(path) || (!download && fs.exists(path + asyncsrv::T__gz))) {
391-
send(beginResponse(fs, path, contentType, download, callback));
392-
} else {
393-
send(404);
394-
}
395-
}
392+
void send(FS &fs, const String &path, const char *contentType = asyncsrv::empty, bool download = false, AwsTemplateProcessor callback = nullptr);
396393
void send(FS &fs, const String &path, const String &contentType, bool download = false, AwsTemplateProcessor callback = nullptr) {
397394
send(fs, path, contentType.c_str(), download, callback);
398395
}

src/WebResponses.cpp

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -673,20 +673,38 @@ void AsyncFileResponse::_setContentTypeFromPath(const String &path) {
673673
AsyncFileResponse::AsyncFileResponse(FS &fs, const String &path, const char *contentType, bool download, AwsTemplateProcessor callback)
674674
: AsyncAbstractResponse(callback) {
675675
_code = 200;
676-
_path = path;
676+
const String gzPath = path + asyncsrv::T__gz;
677677

678-
if (!download && !fs.exists(_path) && fs.exists(_path + T__gz)) {
679-
_path = _path + T__gz;
678+
if (!download && !fs.exists(path) && fs.exists(gzPath)) {
679+
_path = gzPath;
680+
_content = fs.open(gzPath, fs::FileOpenMode::read);
681+
_contentLength = _content.size();
680682
addHeader(T_Content_Encoding, T_gzip, false);
681683
_callback = nullptr; // Unable to process zipped templates
682684
_sendContentLength = true;
683685
_chunked = false;
686+
687+
// CRC32-based ETag of the trailer, bytes 4-7 from the end
688+
_content.seek(_contentLength - 8);
689+
uint8_t crcInTrailer[4];
690+
if (_content.read(crcInTrailer, sizeof(crcInTrailer)) == sizeof(crcInTrailer)) {
691+
char serverETag[9];
692+
AsyncWebServerRequest::_getEtag(crcInTrailer, serverETag);
693+
addHeader(T_ETag, serverETag, false);
694+
addHeader(T_Cache_Control, T_no_cache, false);
695+
}
696+
697+
// Return to the beginning of the file
698+
_content.seek(0);
684699
}
685700

686-
_content = fs.open(_path, fs::FileOpenMode::read);
687-
_contentLength = _content.size();
701+
if (!_content) {
702+
_path = path;
703+
_content = fs.open(path, fs::FileOpenMode::read);
704+
_contentLength = _content.size();
705+
}
688706

689-
if (strlen(contentType) == 0) {
707+
if (*contentType != '\0') {
690708
_setContentTypeFromPath(path);
691709
} else {
692710
_contentType = contentType;

0 commit comments

Comments
 (0)