Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 32 additions & 28 deletions src/AsyncWebServerRequest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,54 +4,58 @@
* @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 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.
*
* @param fs Reference to the filesystem (SPIFFS, LittleFS, etc.).
* @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) {
// Check uncompressed file first
if (fs.exists(path)) {
send(beginResponse(fs, path, contentType, download, callback));
return;
}

// Handle compressed version
const String gzPath = path + asyncsrv::T__gz;
const bool useCompressedVersion = !download && fs.exists(gzPath);
File gzFile = fs.open(gzPath, "r");

// 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);
// Compressed file not found or invalid
if (!gzFile.seek(gzFile.size() - 8)) {
send(404);
gzFile.close();
return;
}

uint8_t crcFromGzipTrailer[4];
if (file.read(crcFromGzipTrailer, sizeof(crcFromGzipTrailer)) == sizeof(crcFromGzipTrailer)) {
char serverETag[9];
_getEtag(crcFromGzipTrailer, serverETag);
// 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 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();
// 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));
}

/**
Expand Down
81 changes: 48 additions & 33 deletions src/WebResponses.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.available()) {
_path = path;
_contentLength = _content.size();
} else {
// Try to open the compressed version (.gz)
_path = path + asyncsrv::T__gz;
_content = fs.open(_path, fs::FileOpenMode::read);
_contentLength = _content.size();
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)) {
if (_content.seek(_contentLength - 8)) {
addHeader(T_Content_Encoding, T_gzip, false);
_callback = nullptr; // Unable to process zipped templates
_sendContentLength = true;
_chunked = false;

// 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);
}
addHeader(T_ETag, serverETag, true);
addHeader(T_Cache_Control, T_no_cache, true);

// Return to the beginning of the file
_content.seek(0);
}

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') {
Expand All @@ -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)
Expand Down