Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
62 changes: 33 additions & 29 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 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.
*
* @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) {
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;
Copy link

Copilot AI Jul 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The download flag is ignored when serving compressed files. You should skip serving the .gz version if download is true to respect the download mode.

Suggested change
const String gzPath = path + asyncsrv::T__gz;
const String gzPath = path + asyncsrv::T__gz;
// Skip serving compressed file if download is true
if (download) {
send(404);
return;
}

Copilot uses AI. Check for mistakes.
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));
}

/**
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) {
_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') {
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
Loading