Skip to content

Commit 7d1afa3

Browse files
JosePineiropre-commit-ci-lite[bot]Copilot
authored
Support for pre-compressed and ETag in download (#222)
* 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) * ci(pre-commit): Apply automatic fixes * Update src/AsyncWebServerRequest.cpp Co-authored-by: Copilot <[email protected]> * Add files via upload --------- Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Copilot <[email protected]>
1 parent 049a659 commit 7d1afa3

File tree

2 files changed

+80
-61
lines changed

2 files changed

+80
-61
lines changed

src/AsyncWebServerRequest.cpp

Lines changed: 32 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,54 +4,58 @@
44
* @brief Sends a file from the filesystem to the client, with optional gzip compression and ETag-based caching.
55
*
66
* 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.
7+
* (with a `.gz` extension) exists and uncompressed version does not exist, it serves the compressed file.
88
* It also handles ETag caching using the CRC32 value from the gzip trailer, responding with `304 Not Modified`
99
* if the client's `If-None-Match` header matches the generated ETag.
1010
*
1111
* @param fs Reference to the filesystem (SPIFFS, LittleFS, etc.).
1212
* @param path Path to the file to be served.
1313
* @param contentType Optional MIME type of the file to be sent.
1414
* 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).
15+
* @param download If true, forces the file to be sent as a download.
1616
* @param callback Optional template processor for dynamic content generation.
1717
* Templates will not be processed in compressed files.
1818
*
1919
* @note If neither the file nor its compressed version exists, responds with `404 Not Found`.
2020
*/
2121
void AsyncWebServerRequest::send(FS &fs, const String &path, const char *contentType, bool download, AwsTemplateProcessor callback) {
22+
// Check uncompressed file first
23+
if (fs.exists(path)) {
24+
send(beginResponse(fs, path, contentType, download, callback));
25+
return;
26+
}
27+
28+
// Handle compressed version
2229
const String gzPath = path + asyncsrv::T__gz;
23-
const bool useCompressedVersion = !download && fs.exists(gzPath);
30+
File gzFile = fs.open(gzPath, "r");
2431

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);
32+
// Compressed file not found or invalid
33+
if (!gzFile.seek(gzFile.size() - 8)) {
34+
send(404);
35+
gzFile.close();
36+
return;
37+
}
3138

32-
uint8_t crcFromGzipTrailer[4];
33-
if (file.read(crcFromGzipTrailer, sizeof(crcFromGzipTrailer)) == sizeof(crcFromGzipTrailer)) {
34-
char serverETag[9];
35-
_getEtag(crcFromGzipTrailer, serverETag);
39+
// ETag validation
40+
if (this->hasHeader(asyncsrv::T_INM)) {
41+
// Generate server ETag from CRC in gzip trailer
42+
uint8_t crcInTrailer[4];
43+
gzFile.read(crcInTrailer, 4);
44+
char serverETag[9];
45+
_getEtag(crcInTrailer, serverETag);
3646

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();
47+
// Compare with client's ETag
48+
const AsyncWebHeader *inmHeader = this->getHeader(asyncsrv::T_INM);
49+
if (inmHeader && inmHeader->value() == serverETag) {
50+
gzFile.close();
51+
this->send(304); // Not Modified
52+
return;
4653
}
4754
}
4855

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-
}
56+
// Send compressed file response
57+
gzFile.close();
58+
send(beginResponse(fs, path, contentType, download, callback));
5559
}
5660

5761
/**

src/WebResponses.cpp

Lines changed: 48 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -670,38 +670,52 @@ void AsyncFileResponse::_setContentTypeFromPath(const String &path) {
670670
#endif
671671
}
672672

673+
/**
674+
* @brief Constructor for AsyncFileResponse that handles file serving with compression support
675+
*
676+
* This constructor creates an AsyncFileResponse object that can serve files from a filesystem,
677+
* with automatic fallback to gzip-compressed versions if the original file is not found.
678+
* It also handles ETag generation for caching and supports both inline and download modes.
679+
*
680+
* @param fs Reference to the filesystem object used to open files
681+
* @param path Path to the file to be served (without compression extension)
682+
* @param contentType MIME type of the file content (empty string for auto-detection)
683+
* @param download If true, file will be served as download attachment; if false, as inline content
684+
* @param callback Template processor callback for dynamic content processing
685+
*/
673686
AsyncFileResponse::AsyncFileResponse(FS &fs, const String &path, const char *contentType, bool download, AwsTemplateProcessor callback)
674687
: AsyncAbstractResponse(callback) {
675-
_code = 200;
676-
const String gzPath = path + asyncsrv::T__gz;
677-
678-
if (!download && !fs.exists(path) && fs.exists(gzPath)) {
679-
_path = gzPath;
680-
_content = fs.open(gzPath, fs::FileOpenMode::read);
688+
// Try to open the uncompressed version first
689+
_content = fs.open(path, fs::FileOpenMode::read);
690+
if (_content.available()) {
691+
_path = path;
692+
_contentLength = _content.size();
693+
} else {
694+
// Try to open the compressed version (.gz)
695+
_path = path + asyncsrv::T__gz;
696+
_content = fs.open(_path, fs::FileOpenMode::read);
681697
_contentLength = _content.size();
682-
addHeader(T_Content_Encoding, T_gzip, false);
683-
_callback = nullptr; // Unable to process zipped templates
684-
_sendContentLength = true;
685-
_chunked = false;
686698

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)) {
699+
if (_content.seek(_contentLength - 8)) {
700+
addHeader(T_Content_Encoding, T_gzip, false);
701+
_callback = nullptr; // Unable to process zipped templates
702+
_sendContentLength = true;
703+
_chunked = false;
704+
705+
// Add ETag and cache headers
706+
uint8_t crcInTrailer[4];
707+
_content.read(crcInTrailer, sizeof(crcInTrailer));
691708
char serverETag[9];
692709
AsyncWebServerRequest::_getEtag(crcInTrailer, serverETag);
693-
addHeader(T_ETag, serverETag, false);
694-
addHeader(T_Cache_Control, T_no_cache, false);
695-
}
710+
addHeader(T_ETag, serverETag, true);
711+
addHeader(T_Cache_Control, T_no_cache, true);
696712

697-
// Return to the beginning of the file
698-
_content.seek(0);
699-
}
700-
701-
if (!_content) {
702-
_path = path;
703-
_content = fs.open(path, fs::FileOpenMode::read);
704-
_contentLength = _content.size();
713+
_content.seek(0);
714+
} else {
715+
// File is corrupted or invalid
716+
_code = 404;
717+
return;
718+
}
705719
}
706720

707721
if (*contentType != '\0') {
@@ -710,18 +724,19 @@ AsyncFileResponse::AsyncFileResponse(FS &fs, const String &path, const char *con
710724
_contentType = contentType;
711725
}
712726

713-
int filenameStart = path.lastIndexOf('/') + 1;
714-
char buf[26 + path.length() - filenameStart];
715-
char *filename = (char *)path.c_str() + filenameStart;
716-
717727
if (download) {
718-
// set filename and force download
728+
// Extract filename from path and set as download attachment
729+
int filenameStart = path.lastIndexOf('/') + 1;
730+
char buf[26 + path.length() - filenameStart];
731+
char *filename = (char *)path.c_str() + filenameStart;
719732
snprintf_P(buf, sizeof(buf), PSTR("attachment; filename=\"%s\""), filename);
733+
addHeader(T_Content_Disposition, buf, false);
720734
} else {
721-
// set filename and force rendering
722-
snprintf_P(buf, sizeof(buf), PSTR("inline"));
735+
// Serve file inline (display in browser)
736+
addHeader(T_Content_Disposition, PSTR("inline"), false);
723737
}
724-
addHeader(T_Content_Disposition, buf, false);
738+
739+
_code = 200;
725740
}
726741

727742
AsyncFileResponse::AsyncFileResponse(File content, const String &path, const char *contentType, bool download, AwsTemplateProcessor callback)

0 commit comments

Comments
 (0)