From c08d7016b89a0c848f2d53f3f31879d25d4dfe57 Mon Sep 17 00:00:00 2001 From: Mitch Bradley Date: Tue, 18 Nov 2025 12:37:25 -1000 Subject: [PATCH 1/5] Support WebDAV methods From https://github.com/rostwolke/ESPAsyncWebdav See also https://github.com/me-no-dev/ESPAsyncWebServer/pull/676 --- src/ESPAsyncWebServer.h | 26 ++++++++++++++++--------- src/WebRequest.cpp | 42 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/src/ESPAsyncWebServer.h b/src/ESPAsyncWebServer.h index 7d5eea17e..738fd24d4 100644 --- a/src/ESPAsyncWebServer.h +++ b/src/ESPAsyncWebServer.h @@ -61,14 +61,22 @@ typedef enum http_method WebRequestMethod; #else #ifndef WEBSERVER_H typedef enum { - HTTP_GET = 0b00000001, - HTTP_POST = 0b00000010, - HTTP_DELETE = 0b00000100, - HTTP_PUT = 0b00001000, - HTTP_PATCH = 0b00010000, - HTTP_HEAD = 0b00100000, - HTTP_OPTIONS = 0b01000000, - HTTP_ANY = 0b01111111, + HTTP_GET = 0b0000000000000001, + HTTP_POST = 0b0000000000000010, + HTTP_DELETE = 0b0000000000000100, + HTTP_PUT = 0b0000000000001000, + HTTP_PATCH = 0b0000000000010000, + HTTP_HEAD = 0b0000000000100000, + HTTP_OPTIONS = 0b0000000001000000, + HTTP_PROPFIND = 0b0000000010000000, + HTTP_LOCK = 0b0000000100000000, + HTTP_UNLOCK = 0b0000001000000000, + HTTP_PROPPATCH = 0b0000010000000000, + HTTP_MKCOL = 0b0000100000000000, + HTTP_MOVE = 0b0001000000000000, + HTTP_COPY = 0b0010000000000000, + HTTP_RESERVED = 0b0100000000000000, + HTTP_ANY = 0b0111111111111111, } WebRequestMethod; #endif #endif @@ -90,7 +98,7 @@ class FileOpenMode { #define RESPONSE_TRY_AGAIN 0xFFFFFFFF #define RESPONSE_STREAM_BUFFER_SIZE 1460 -typedef uint8_t WebRequestMethodComposite; +typedef uint16_t WebRequestMethodComposite; typedef std::function ArDisconnectHandler; /* diff --git a/src/WebRequest.cpp b/src/WebRequest.cpp index 5bbe0b723..6be45f623 100644 --- a/src/WebRequest.cpp +++ b/src/WebRequest.cpp @@ -317,6 +317,24 @@ bool AsyncWebServerRequest::_parseReqHead() { _method = HTTP_HEAD; } else if (m == T_OPTIONS) { _method = HTTP_OPTIONS; + } else if(m == "PROPFIND"){ + _method = HTTP_PROPFIND; + } else if(m == "LOCK"){ + _method = HTTP_LOCK; + } else if(m == "UNLOCK"){ + _method = HTTP_UNLOCK; + } else if(m == "PROPPATCH"){ + _method = HTTP_PROPPATCH; + } else if(m == "MKCOL"){ + _method = HTTP_MKCOL; + } else if(m == "MOVE"){ + _method = HTTP_MOVE; + } else if(m == "COPY"){ + _method = HTTP_COPY; + } else if(m == "RESERVED"){ + _method = HTTP_RESERVED; + } else if(m == "ANY"){ + _method = HTTP_ANY; } else { return false; } @@ -1157,6 +1175,30 @@ const char *AsyncWebServerRequest::methodToString() const { if (_method & HTTP_OPTIONS) { return T_OPTIONS; } + if (_method & HTTP_PROPFIND) { + return "PROPFIND"; + } + if (_method & HTTP_LOCK) { + return "LOCK"; + } + if (_method & HTTP_UNLOCK) { + return "UNLOCK"; + } + if (_method & HTTP_PROPPATCH) { + return "PROPPATCH"; + } + if (_method & HTTP_MKCOL) { + return "MKCOL"; + } + if (_method & HTTP_MOVE) { + return "MOVE"; + } + if (_method & HTTP_COPY) { + return "COPY"; + } + if (_method & HTTP_RESERVED) { + return "RESERVED"; + } return T_UNKNOWN; } From ac3166f81779fa20ce13ab7040e0f2a48cf4f864 Mon Sep 17 00:00:00 2001 From: Mitch Bradley Date: Sat, 24 Jan 2026 15:22:58 -0800 Subject: [PATCH 2/5] Support chunked encoding in requests --- src/ESPAsyncWebServer.h | 6 ++ src/WebRequest.cpp | 195 ++++++++++++++++++++++++++++++---------- src/literals.h | 2 + 3 files changed, 157 insertions(+), 46 deletions(-) diff --git a/src/ESPAsyncWebServer.h b/src/ESPAsyncWebServer.h index 68939e567..b4ca964d4 100644 --- a/src/ESPAsyncWebServer.h +++ b/src/ESPAsyncWebServer.h @@ -288,6 +288,12 @@ class AsyncWebServerRequest { size_t _itemBufferIndex; bool _itemIsFile; + size_t _chunkStartIndex; // Offset from start of the chunked data stream + size_t _chunkOffset; // Offset into the current chunk + size_t _chunkSize; // Size of the current chunk + uint8_t _chunkedParseState; + bool _parseChunkedBytes(uint8_t *data, size_t len); + void _onPoll(); void _onAck(size_t len, uint32_t time); void _onError(int8_t error); diff --git a/src/WebRequest.cpp b/src/WebRequest.cpp index d2b142fb3..832bc4f1e 100644 --- a/src/WebRequest.cpp +++ b/src/WebRequest.cpp @@ -29,11 +29,20 @@ enum { PARSE_REQ_FAIL = 4 }; +enum { + CHUNK_NONE = 0, // Body transfer encoding is not chunked + CHUNK_LENGTH, // Getting chunk length - HHHH[;...] CR LF + CHUNK_EXTENSION, // Getting chunk length - HHHH[;...] CR LF + CHUNK_DATA, // Handling chunk data + CHUNK_END, // Getting chunk end marker - CR LF +}; + AsyncWebServerRequest::AsyncWebServerRequest(AsyncWebServer *s, AsyncClient *c) : _client(c), _server(s), _handler(NULL), _response(NULL), _onDisconnectfn(NULL), _temp(), _parseState(PARSE_REQ_START), _version(0), _method(HTTP_ANY), _url(), _host(), _contentType(), _boundary(), _authorization(), _reqconntype(RCT_HTTP), _authMethod(AsyncAuthType::AUTH_NONE), _isMultipart(false), _isPlainPost(false), _expectingContinue(false), _contentLength(0), _parsedLength(0), _multiParseState(0), _boundaryPosition(0), _itemStartIndex(0), - _itemSize(0), _itemName(), _itemFilename(), _itemType(), _itemValue(), _itemBuffer(0), _itemBufferIndex(0), _itemIsFile(false), _tempObject(NULL) { + _itemSize(0), _itemName(), _itemFilename(), _itemType(), _itemValue(), _itemBuffer(0), _itemBufferIndex(0), _itemIsFile(false), _tempObject(NULL), + _chunkedParseState(CHUNK_NONE) { c->onError( [](void *r, AsyncClient *c, int8_t error) { (void)c; @@ -164,56 +173,64 @@ void AsyncWebServerRequest::_onData(void *buf, size_t len) { } } } else if (_parseState == PARSE_REQ_BODY) { - // A handler should be already attached at this point in _parseLine function. - // If handler does nothing (_onRequest is NULL), we don't need to really parse the body. - const bool needParse = _handler && !_handler->isRequestHandlerTrivial(); - // Discard any bytes after content length; handlers may overrun their buffers - len = std::min(len, _contentLength - _parsedLength); - if (_isMultipart) { - if (needParse) { - size_t i; - for (i = 0; i < len; i++) { - _parseMultipartPostByte(((uint8_t *)buf)[i], i == len - 1); - _parsedLength++; - } - } else { - _parsedLength += len; + if (_chunkedParseState != CHUNK_NONE) { + if (_parseChunkedBytes((uint8_t *)buf, len)) { + _parseState = PARSE_REQ_END; + _runMiddlewareChain(); + _send(); } } else { - if (_parsedLength == 0) { - if (_contentType.startsWith(T_app_xform_urlencoded)) { - _isPlainPost = true; - } else if (_contentType == T_text_plain && isParamChar(((char *)buf)[0])) { - size_t i = 0; - char ch; - do { - ch = ((char *)buf)[i]; - } while (i++ < len && isParamChar(ch)); - if (i < len && ((char *)buf)[i - 1] == '=') { - _isPlainPost = true; + // A handler should be already attached at this point in _parseLine function. + // If handler does nothing (_onRequest is NULL), we don't need to really parse the body. + const bool needParse = _handler && !_handler->isRequestHandlerTrivial(); + // Discard any bytes after content length; handlers may overrun their buffers + len = std::min(len, _contentLength - _parsedLength); + if (_isMultipart) { + if (needParse) { + size_t i; + for (i = 0; i < len; i++) { + _parseMultipartPostByte(((uint8_t *)buf)[i], i == len - 1); + _parsedLength++; } + } else { + _parsedLength += len; } - } - if (!_isPlainPost) { - // ESP_LOGD("AsyncWebServer", "_isPlainPost: %d, _handler: %p", _isPlainPost, _handler); - if (_handler) { - _handler->handleBody(this, (uint8_t *)buf, len, _parsedLength, _contentLength); + } else { + if (_parsedLength == 0) { + if (_contentType.startsWith(T_app_xform_urlencoded)) { + _isPlainPost = true; + } else if (_contentType == T_text_plain && isParamChar(((char *)buf)[0])) { + size_t i = 0; + char ch; + do { + ch = ((char *)buf)[i]; + } while (i++ < len && isParamChar(ch)); + if (i < len && ((char *)buf)[i - 1] == '=') { + _isPlainPost = true; + } + } } - _parsedLength += len; - } else if (needParse) { - size_t i; - for (i = 0; i < len; i++) { - _parsedLength++; - _parsePlainPostChar(((uint8_t *)buf)[i]); + if (!_isPlainPost) { + // ESP_LOGD("AsyncWebServer", "_isPlainPost: %d, _handler: %p", _isPlainPost, _handler); + if (_handler) { + _handler->handleBody(this, (uint8_t *)buf, len, _parsedLength, _contentLength); + } + _parsedLength += len; + } else if (needParse) { + size_t i; + for (i = 0; i < len; i++) { + _parsedLength++; + _parsePlainPostChar(((uint8_t *)buf)[i]); + } + } else { + _parsedLength += len; } - } else { - _parsedLength += len; } - } - if (_parsedLength == _contentLength) { - _parseState = PARSE_REQ_END; - _runMiddlewareChain(); - _send(); + if (_parsedLength == _contentLength) { + _parseState = PARSE_REQ_END; + _runMiddlewareChain(); + _send(); + } } } break; @@ -352,6 +369,78 @@ bool AsyncWebServerRequest::_parseReqHead() { return true; } +// Returns true when done +bool AsyncWebServerRequest::_parseChunkedBytes(uint8_t *buf, size_t len) { + for (size_t i = 0; i < len;) { + if (_chunkedParseState == CHUNK_DATA) { + // In DATA state, we pass the bytes off to handleBody as a group + + // In order to avoid allocating an extra buffer, the data + // blocks that we pass on do not necessarily correspond to + // whole chunks. We just send however much we already have, + // anticipating that more will arrive later. handleBody() + // cannot assume that it receives entire chunks at once. + // That should not be a problem because we do not attach + // any semantic meaning to chunks. That might change if + // we were to support chunk extensions, but that seems + // unlikely since RFC9112 suggests that they are only + // useful for very specialized purposes. + size_t curLen = std::min(_chunkSize - _chunkOffset, len - i); + + // On the final zero-length chunk, _chunkSize - _chunkOffset + // will be zero, so we will call handleBody with a zero size, + // marking the end of the data stream. + + if (_handler) { + _handler->handleBody(this, buf + i, curLen, _chunkStartIndex, _contentLength); + } + _chunkOffset += curLen; + _chunkStartIndex += curLen; + i += curLen; + if (_chunkOffset == _chunkSize) { + _chunkedParseState = CHUNK_END; + } + } else { + // In other states we process the bytes one by one + uint8_t data = buf[i++]; + + if (_chunkedParseState == CHUNK_LENGTH) { + // Incrementally decode a hex number + if (data >= '0' && data <= '9') { + _chunkSize = (_chunkSize * 16) + (data - '0'); + } else if (data >= 'A' && data <= 'F') { + _chunkSize = (_chunkSize * 16) + (data - 'A' + 10); + } else if (data >= 'a' && data <= 'f') { + _chunkSize = (_chunkSize * 16) + (data - 'a' + 10); + } else if (data == ';') { + _chunkedParseState = CHUNK_EXTENSION; + } else if (data == '\n') { + _chunkOffset = 0; + _chunkedParseState = CHUNK_DATA; + } + } else if (_chunkedParseState == CHUNK_EXTENSION) { + if (data == '\n') { + // A zero length chunk marks the end of the chunk stream + _chunkOffset = 0; + _chunkedParseState = CHUNK_DATA; + } + } else if (_chunkedParseState == CHUNK_END) { + if (data == '\n') { + if (_chunkSize == 0) { + // If we needed to support trailers, we would switch to + // TRAILER state, but since we have no use case for them, + // we just stop processing the body. + return true; + } + _chunkSize = 0; + _chunkedParseState = CHUNK_LENGTH; + } + } + } + } + return false; +} + bool AsyncWebServerRequest::_parseReqHeader() { AsyncWebHeader header = AsyncWebHeader::parse(_temp); if (header) { @@ -366,7 +455,10 @@ bool AsyncWebServerRequest::_parseReqHeader() { _boundary.replace(String('"'), String()); _isMultipart = true; } - } else if (name.equalsIgnoreCase(T_Content_Length)) { + } else if (name.equalsIgnoreCase(T_Content_Length) || name.equalsIgnoreCase(T_X_Expected_Entity_Length)) { + // MacOS WebDAVFS uses X-Expected-Entity-Length to indicate the + // total length of a chunked request body. It is useful to + // determine if a PUT can possibly fit in the available space. _contentLength = atoi(value.c_str()); } else if (name.equalsIgnoreCase(T_EXPECT) && value.equalsIgnoreCase(T_100_CONTINUE)) { _expectingContinue = true; @@ -403,6 +495,17 @@ bool AsyncWebServerRequest::_parseReqHeader() { // WebEvent request can be uniquely identified by header: [Accept: text/event-stream] _reqconntype = RCT_EVENT; } + } else if (name.equalsIgnoreCase(T_Transfer_Encoding)) { + String lowcase(value); + lowcase.toLowerCase(); + + if (lowcase.indexOf("chunked") != -1) { + _chunkSize = 0; + _chunkStartIndex = 0; + _chunkedParseState = CHUNK_LENGTH; + _itemIsFile = true; + _itemFilename = _url; + } } _headers.emplace_back(std::move(header)); } @@ -698,7 +801,7 @@ void AsyncWebServerRequest::_parseLine() { String response(T_HTTP_100_CONT); _client->write(response.c_str(), response.length()); } - if (_contentLength) { + if (_contentLength || _chunkedParseState != CHUNK_NONE) { _parseState = PARSE_REQ_BODY; } else { _parseState = PARSE_REQ_END; diff --git a/src/literals.h b/src/literals.h index 52ecb6c2d..f1efdadc4 100644 --- a/src/literals.h +++ b/src/literals.h @@ -101,6 +101,7 @@ static constexpr const char T_uri[] = "uri"; static constexpr const char T_username[] = "username"; static constexpr const char T_WS[] = "websocket"; static constexpr const char T_WWW_AUTH[] = "WWW-Authenticate"; +static constexpr const char T_X_Expected_Entity_Length[] = "X-Expected-Entity-Length"; // HTTP Methods static constexpr const char T_ANY[] = "ANY"; @@ -183,6 +184,7 @@ DECLARE_STR(T_HTTP_CODE_203, "Non-Authoritative Information"); DECLARE_STR(T_HTTP_CODE_204, "No Content"); DECLARE_STR(T_HTTP_CODE_205, "Reset Content"); DECLARE_STR(T_HTTP_CODE_206, "Partial Content"); +DECLARE_STR(T_HTTP_CODE_207, "Multi Status"); DECLARE_STR(T_HTTP_CODE_300, "Multiple Choices"); DECLARE_STR(T_HTTP_CODE_301, "Moved Permanently"); DECLARE_STR(T_HTTP_CODE_302, "Found"); From a4163586352a6e0c6548a70066115f9fe67d208a Mon Sep 17 00:00:00 2001 From: Mitch Bradley Date: Sat, 24 Jan 2026 15:25:50 -0800 Subject: [PATCH 3/5] Added test app for WebDAV and chunked requests --- examples/WebDAV/WebDAV.ino | 629 +++++++++++++++++++++++++++++++++ examples/WebDAV/WiFiConfig.h | 11 + examples/WebDAV/platformio.ini | 25 ++ 3 files changed, 665 insertions(+) create mode 100644 examples/WebDAV/WebDAV.ino create mode 100644 examples/WebDAV/WiFiConfig.h create mode 100644 examples/WebDAV/platformio.ini diff --git a/examples/WebDAV/WebDAV.ino b/examples/WebDAV/WebDAV.ino new file mode 100644 index 000000000..35b4c9d18 --- /dev/null +++ b/examples/WebDAV/WebDAV.ino @@ -0,0 +1,629 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright 2016-2025 Hristo Gochkov, Mathieu Carbou, Emil Muratov +// Copyright 2026 Mitch Bradley + +// +// - WebDAV Server to access LittleFS files +// - Includes tests for chunked encoding in requests, as used +// by MacOS WebDAVFS + +#include +#if defined(ESP32) || defined(LIBRETINY) +#include +#include +#include +#elif defined(ESP8266) +#include +#include +#elif defined(TARGET_RP2040) || defined(TARGET_RP2350) || defined(PICO_RP2040) || defined(PICO_RP2350) +#include +#include +#endif + +#include + +#include + +#include +#include + +using namespace asyncsrv; + +// Tests: +// Note: The '-4' curl argument prevents long IPv6 mDNS resolution timeouts +// The tests will work without it, but will take several seconds to start. +// If you use the dotted decimal IP address, the -4 is unnecessary. +// +// Get the OPTIONS that the WebDAV server supports +// curl -4 -v -X OPTIONS http://davtest.local +// ** Note: The -v is necessary to see the option values because they +// ** in the Allow: response header instead of the body. +// List a directory with PROPFIND +// curl -4 -X PROPFIND -H 'Depth: 1' http://davtest.local/ +// List a directory with pretty-printed output +// curl -4 -X PROPFIND -H 'Depth: 1' http://davtest.local/ | xmllint --format - +// List a directory showing only the names +// curl -4 -X PROPFIND -H 'Depth: 1' http://davtest.local/ | xmllint --format - | grep href +// Upload a file with PUT +// curl -4 -T myfile.txt http://davtest.local/ +// Upload a file with PUT using chunked encoding +// curl -4 -T bigfile.txt -H 'Transfer-Encoding: chunked' http://davtest.local/ +// ** Note: If the file will not fit in the available space, the server +// ** does not know that in advance due to the lack of a Content-Length header. +// ** The transfer will proceed until the filesystem fills up, then the transfer +// ** will fail and the partial file will be deleted. +// Immediately reject a chunked PUT that will not fit in available space +// curl -4 -T bigfile.txt -H 'Transfer-Encoding: chunked' -H 'X-Expected-Entity-Length: 99999999' http://davtest.local/ +// ** Note: MacOS WebDAVFS supplies the X-Expected-Entity-Length header with its +// ** chunked PUTs +// Download a file with GET (result to stdout) +// curl -4 http://davtest.local/myfile.txt +// Delete a file with DELETE +// curl -4 -X DELETED http://davtest.local/myfile.txt +// Create a subdirectory with MKCOL +// curl -4 -X MKCOL http://davtest.local/TheSubdir +// Upload a file to subdirectory +// curl -4 -T anotherfile.txt http://davtest.local/TheSubdir/ +// ** Note: without the trailing / the request will fail +// Lock a file against other access +// curl -4 -X LOCK http://davtest.local/myfile.txt +// ** Note: Locks return a token but otherwise do nothing +// ** other than making MacOS WebDAVFS happy +// Unlock a previously-locked file +// curl -4 -X UNLOCK http://davtest.local/myfile.txt +// Rename oldfile.txt to newfile.txt +// curl -4 -X MOVE -H 'Destination: http://davtest.local/newfile.txt' http://davtest.local/oldfile.txt + +#ifndef ESP32 +// this example is only for the ESP32 +void setup() {} +void loop() {} +#else + +struct mime_type { + const char *suffix; + const char *mime_type; +} mime_types[] = { + {".htm", "text/html"}, {".html", "text/html"}, + {".css", "text/css"}, {".js", "application/javascript"}, + {".png", "image/png"}, {".gif", "image/gif"}, + {".jpeg", "image/jpeg"}, {".jpg", "image/jpeg"}, + {".ico", "image/x-icon"}, {".xml", "text/xml"}, + {".pdf", "application/x-pdf"}, {".zip", "application/x-zip"}, + {".gz", "application/x-gzip"}, {".txt", "text/plain"}, + {".gc", "text/plain"}, {".gcode", "text/plain"}, + {".nc", "text/plain"}, {"", "application/octet-stream"}, +}; + +const char *getContentType(const String &filename) { + String lcname(filename); + lcname.toLowerCase(); + mime_type *m; + for (m = mime_types; *(m->suffix) != '\0'; ++m) { + if (lcname.endsWith(m->suffix)) { + return m->mime_type; + } + } + return m->mime_type; +} + +struct RequestState { + File outFile; +}; + +class WebDAV : public AsyncWebHandler { + +public: + WebDAV(const String &url, FS &volume); + + bool canHandle(AsyncWebServerRequest *request) const override final; + void handleRequest(AsyncWebServerRequest *request) override final; + void handleBody(AsyncWebServerRequest *request, unsigned char *data, size_t len, size_t index, size_t total) override final; + + const char *url() const { + return _url.c_str(); + } + +private: + String _url; + FS &_fs; + + void handlePropfind(const String &path, AsyncWebServerRequest *request); + void handleGet(const String &path, AsyncWebServerRequest *request); + void handleNotFound(AsyncWebServerRequest *request); + void sendPropResponse(AsyncResponseStream *response, int level, const String &path); + + void sendItem(AsyncResponseStream *response, bool is_dir, const String &name, const String &tag, const String &time, size_t size); + + String urlToUri(String url); + + bool acceptsType(AsyncWebServerRequest *request, const char *); + bool acceptsEncoding(AsyncWebServerRequest *request, const char *); +}; + +WebDAV::WebDAV(const String &url, FS &fs) : _url(url), _fs(fs) {} + +bool WebDAV::canHandle(AsyncWebServerRequest *request) const { + return request->url().startsWith(_url.c_str()); +} + +static const char* rootname = "/"; +static const char* slashstr = "/"; +static const char slash = '/'; + +// Mac command to prevent .DS_Store files: +// defaults write com.apple.desktopservices DSDontWriteNetworkStores -bool TRUE +// Mac metadata files: +// .metadata_never_index_unless_rootfs +// .metadata_never_index +// .Spotlight-V100 +// .DS_Store +// ._* (metadata for the file *) +// .hidden + + std::map reqNames = { + { 1, "GET" }, { 2, "POST" }, { 4, "DELETE" }, { 8, "PUT" }, { 16, "PATCH" }, + { 32, "HEAD" }, { 64, "OPTIONS" }, { 128, "PROPFIND" }, { 256, "LOCK" }, { 512, "UNLOCK" }, + { 1024, "PROPATCH" }, { 2048, "MKCOL" }, { 4096, "MOVE" }, { 8192, "COPY" }, { 16384, "RESERVED" }, +}; + + void WebDAV::handleRequest(AsyncWebServerRequest *request) { + // If request->_tempObject is not null, handleBody already + // did the necessary work for a PUT operation + auto state = static_cast(request->_tempObject); + if (state) { + if (state->outFile) { + // The file was already opened and written in handleBody so + // we are done. We will handle PUT without body data below. + state->outFile.close(); + request->send(201); // Created + } + delete state; + request->_tempObject = nullptr; + return; + } + + String path = request->url(); + + Serial.print(reqNames.find(request->method())->second); + Serial.print(" "); + Serial.println(path); + + if (request->method() == HTTP_MKCOL) { + Serial.println("MKCOL"); + // does the file/dir already exist? + int status; + if (_fs.exists(path)) { + // Already exists + // I think there is an "Overwrite: {T,F}" header; we should handle it + request->send(405); + } else { + request->send(_fs.mkdir(path) ? 201 : 405); + } + return; + } + + if (request->method() == HTTP_PUT) { + // This PUT code executes if the body was empty, which + // can happen if the client creates a zero-length file. + // MacOS WebDAVFS does that, then later LOCKs the file + // and issues a subsequent PUT with body contents. + + File file = _fs.open(path, FILE_WRITE, true); + if (file) { + file.close(); + request->send(201); // Created + return; + } + request->send(403); + return; + } + + if (request->method() == HTTP_OPTIONS) { + AsyncWebServerResponse *response = request->beginResponse(200); + response->addHeader("Dav", "1,2"); + response->addHeader("Ms-Author-Via", "DAV"); + response->addHeader("Allow", "PROPFIND,OPTIONS,DELETE,MOVE,HEAD,POST,PUT,GET"); + request->send(response); + return; + } + + // If we are not creating the resource it must already exist + if (!_fs.exists(path)) { + Serial.printf("%s does not exist\n", path.c_str()); + request->send(404, "Resource does not exist"); + return; + } + + if (request->method() == HTTP_HEAD) { + File file = _fs.open(path); + + // HEAD is like GET without a body, but with Content-Length + AsyncWebServerResponse *response = request->beginResponse(200, getContentType(path), ""); // AsyncBasicResponse + response->setContentLength(file.size()); + + request->send(response); + return; + } + + if (request->method() == HTTP_GET) { + return handleGet(path, request); + } + + if (request->method() == HTTP_PROPFIND) { + handlePropfind(path, request); + return; + } + + if (request->method() == HTTP_LOCK) { + Serial.println("LOCK"); + + String lockroot("http://"); + lockroot += request->host(); + lockroot += path; + + AsyncResponseStream *response = request->beginResponseStream("application/xml; charset=utf-8"); + response->setCode(200); + response->addHeader("Lock-Token", "urn:uuid:26e57cb3-834d-191a-00de-000042bdecf9"); + + response->print(""); + response->print(""); + response->print(""); + response->print(""); + response->print(""); + response->print(""); + response->print("urn:uuid:26e57cb3-834d-191a-00de-000042bdecf9"); + response->printf("%s", lockroot.c_str()); + response->print("infinity"); + response->printf("%s", "todo"); + response->print("Second-3600"); + response->print(""); + response->print(""); + response->print(""); + + request->send(response); + return; + } + if (request->method() == HTTP_UNLOCK) { + request->send(204); // No Content + return; + } + if (request->method() == HTTP_MOVE) { + const AsyncWebHeader *destinationHeader = request->getHeader("destination"); + if (!destinationHeader || destinationHeader->value().isEmpty()) { + request->send(400, "text/plain", "Missing destination header"); + return; + } + + // Should handle "Overwrite: {T,F}" header + String newpath = urlToUri(destinationHeader->value()); + Serial.printf("Renaming %s to %s\n", path.c_str(), newpath.c_str()); + + if (_fs.exists(newpath)) { + Serial.printf("Destination file %s already exists\n", newpath.c_str()); + request->send(500, "text/plain", "Destination file exists"); + } else { + if (_fs.rename(path, newpath)) { + Serial.println("Rename succeeded"); + request->send(201); + } else { + Serial.println("Rename failed"); + request->send(500, "text/plain", "Unable to move"); + } + } + + return; + } + if (request->method() == HTTP_DELETE) { + // delete file or dir + bool result; + File file = _fs.open(path); + + if (!file) { + request->send(404); + } else { + if (file.isDirectory()) { + file.close(); + request->send(_fs.rmdir(path) ? 200 : 201); + } else { + file.close(); + request->send(_fs.remove(path) ? 200 : 201); + } + } + return; + } + + handleNotFound(request); + } + + void WebDAV::handleBody(AsyncWebServerRequest *request, unsigned char *data, size_t len, size_t index, size_t total) { + // The other requests with a body are LOCK and PROPFIND, where the body data is the XML + // schema for their replies. For now, we just ignore that data and hardcode the reply + // schema. It might be useful to decode the schema to, for example, omit some reply fields, + // but that doesn't appear to be necessary at the moment. + if (request->method() == HTTP_PUT) { + auto state = static_cast(request->_tempObject); + if (index == 0) { + // parse the url to a proper path + String path = request->url(); + + state = new RequestState{File()}; + request->_tempObject = static_cast(state); + + if (total) { + // Ideally this would be _fs.totalBytes() - _fs.usedBytes() + // but the Arduino FS class does not have totalBytes() + // and usedBytes(). They exist in the SDFS and LittleFS + // classes, but with different return types (uint64_t for + // SDFS and uint32_t for LittleFS). + // This should be abstracted somewhere but I have to draw + // the line somewhere with this test code. + size_t avail = LittleFS.totalBytes() - LittleFS.usedBytes(); + avail -= 4096; // Reserve a block for overhead + if (total > avail) { + Serial.printf("PUT %d bytes will not fit in available space (%d).\n", total, avail); + request->send(507); // Insufficient storage + return; + } + } + Serial.print("PUT: Opening "); + Serial.println(path); + + File file = _fs.open(path, FILE_WRITE, true); + if (!file) { + request->send(500); + return; + } + if (file.isDirectory()) { + file.close(); + Serial.println("Cannot PUT to a directory"); + request->send(403); + return; + } + // If we already returned, the File object in request->_tempObject + // is the default-contructed one. The presence of + + std::swap(state->outFile, file); + // Now request->_tempObject contains the actual file object which owns it, + // and default-constructed File() object is in file, which will + // go out of scope + } + if (state && state->outFile) { + Serial.printf("write %d at %d\n", len, index); + auto actual = state->outFile.write(data, len); + if (actual != len) { + Serial.println("WebDAV write failed. Deleting file."); + + // Replace the File object in state with a null one + File file{}; + std::swap(state->outFile, file); + file.close(); + + String path = request->url(); + _fs.remove(path); + request->send(507); // Insufficient storage + return; + } + } + } + } + + bool WebDAV::acceptsEncoding(AsyncWebServerRequest *request, const char *encoding) { + if (request->hasHeader("Accept-Encoding")) { + auto encodings = String(request->getHeader("Accept-Encoding")->value().c_str()); + return encodings.indexOf(encoding) != -1; + } + return false; + } + +bool WebDAV::acceptsType(AsyncWebServerRequest *request, const char *type) { + if (request->hasHeader(T_ACCEPT)) { + auto types = String(request->getHeader(T_ACCEPT)->value().c_str()); + return types.indexOf(type) != -1; + } + return false; +} + +void WebDAV::handlePropfind(const String &path, AsyncWebServerRequest *request) { + auto depth = 0; + + bool noroot = false; + + const AsyncWebHeader *depthHeader = request->getHeader("Depth"); + if (depthHeader) { + if (depthHeader->value().equals("1")) { + depth = 1; + } else if (depthHeader->value().equals("1,noroot")) { + depth = 1; + noroot = true; + } else if (depthHeader->value().equals("infinity")) { + depth = 99999; + } else if (depthHeader->value().equals("infinity,noroot")) { + depth = 99999; + noroot = true; + } + } + + AsyncResponseStream *response = request->beginResponseStream("application/xml"); + response->setCode(207); + + response->print(""); + response->print(""); + + sendPropResponse(response, (int)depth, path); + + response->print(""); + + request->send(response); + return; +} + +void WebDAV::handleGet(const String &path, AsyncWebServerRequest *request) { + File file = _fs.open(path); + + if (!file) { + Serial.printf("%s not found\n", path.c_str()); + request->send(404); + return; + } + + AsyncWebServerResponse *response = + request->beginResponse(getContentType(path), file.size(), [file, request](uint8_t *buffer, size_t maxLen, size_t total) mutable -> size_t { + if (!file) { + request->client()->close(); + return 0; //RESPONSE_TRY_AGAIN; // This only works for ChunkedResponse + } + if (total >= file.size() || request->method() != HTTP_GET) { + file.close(); + return 0; + } + int bytes = min(file.size(), maxLen); + int actual = file.read(buffer, bytes); // return 0 even when no bytes were loaded + if (bytes == 0 || (bytes + total) >= file.size()) { + file.close(); + } + return bytes; + }); + + request->onDisconnect([request, file]() mutable { + file.close(); + }); + + request->send(response); +} + +void WebDAV::handleNotFound(AsyncWebServerRequest *request) { + request->send(404); +} + +String WebDAV::urlToUri(String url) { + String uri(url); + if (uri.startsWith("http://")) { + uri = uri.substring(7); + } else if (uri.startsWith("https://")) { + uri = uri.substring(8); + } + // Now remove the hostname. + + auto pos = uri.indexOf('/'); + if (pos != -1) { + uri = uri.substring(pos); + } + return uri; +} + +void WebDAV::sendItem(AsyncResponseStream *response, bool is_dir, const String &name, const String &tag, const String &time, size_t size) { + response->printf(""); + response->printf("%s", name.c_str()); + response->printf(""); + response->printf(""); + if (is_dir) { + response->printf(""); + } else { + // response->printf("%s", tag.c_str()); + response->printf("%s", time.c_str()); + response->printf(""); + response->printf("%d", size); + response->printf("%s", getContentType(name)); + } + response->printf(""); + response->printf("HTTP/1.1 200 OK"); + response->printf(""); + response->printf(""); +} + +void WebDAV::sendPropResponse(AsyncResponseStream *response, int level, const String &path) { + File file = _fs.open(path); + bool is_dir = file.isDirectory(); + size_t size = file.size(); + + // Just fake the time + String timestr = "Fri, 05 Sep 2014 19:00:00 GMT"; + + // send response + String tag(path + timestr); + + sendItem(response, is_dir, path, tag, timestr, size); + + if (is_dir && level--) { + String name; + while ((name = file.getNextFileName()) != "") { + sendPropResponse(response, level, name); + } + } +} + +static AsyncWebServer server(80); +static AsyncHeaderFreeMiddleware *headerFilter; + +void setup() { + Serial.begin(115200); + Serial.println("Setting up WiFi"); + +#ifdef STA_SSID + Serial.println("Using STA mode"); + WiFi.mode(WIFI_STA); + WiFi.setSleep(false); + WiFi.begin(STA_SSID, STA_PASSWORD); + Serial.print("Connecting to WiFi .."); + + // Wait for connection + while (WiFi.status() != WL_CONNECTED) { + delay(1000); + Serial.print('.'); + } + Serial.println(WiFi.localIP()); + +#else + IPAddress local_IP(192, 168, AP_SUBNET, 1); + IPAddress gateway(192, 168, AP_SUBNET, 1); + IPAddress subnet(255, 255, 255, 0); + WiFi.softAPConfig(local_IP, gateway, subnet); + WiFi.mode(WIFI_AP); + WiFi.softAP(AP_SSID); + Serial.print("Starting AP "); + Serial.print(AP_SSID); + Serial.print(" "); + Serial.println(WiFi.softAPIP()); +#endif + +#ifdef MDNS_NAME + // Initialize mDNS + if (!MDNS.begin(MDNS_NAME)) { + Serial.println("Error setting up MDNS responder!"); + while (1) { + delay(1000); + } + } + Serial.printf("mDNS on: %s.local\n", MDNS_NAME); + + // Add service to mDNS + MDNS.addService("http", "tcp", 80); +#endif + + headerFilter = new AsyncHeaderFreeMiddleware(); + + headerFilter->keep("Depth"); + headerFilter->keep("Destination"); + + server.addMiddlewares({headerFilter}); + + LittleFS.begin(true); + Serial.print("LittleFS uses "); + Serial.print(LittleFS.usedBytes()); + Serial.print(" of "); + Serial.println(LittleFS.totalBytes()); + + auto flash_dav = new WebDAV("/", LittleFS); + server.addHandler(flash_dav); + + // server.on("/", HTTP_ANY, handle_roo); + + server.begin(); +} + +void loop() { + delay(100); +} + +#endif diff --git a/examples/WebDAV/WiFiConfig.h b/examples/WebDAV/WiFiConfig.h new file mode 100644 index 000000000..37a850930 --- /dev/null +++ b/examples/WebDAV/WiFiConfig.h @@ -0,0 +1,11 @@ +// Define the following to use STA mode + +// #define STA_SSID "MySSID" +// #define STA_PASSWORD "MyPassword" + +// If STA_SSID is not defined AP mode will be used + +#define AP_SSID "esp-captive" +#define AP_SUBNET 4 + +#define MDNS_NAME "davtest" diff --git a/examples/WebDAV/platformio.ini b/examples/WebDAV/platformio.ini new file mode 100644 index 000000000..dc39a547f --- /dev/null +++ b/examples/WebDAV/platformio.ini @@ -0,0 +1,25 @@ +[platformio] +src_dir = . + +[env:esp32] + +; As of 2026-01-24, the version of arduino in the platformio registry +; is old with a old buggy version of littlefs. The pioarduino fork is up to date +platform = https://github.com/pioarduino/platform-espressif32.git +board = esp32dev +framework = arduino +monitor_speed = 115200 +monitor_filters=esp32_exception_decoder + +lib_deps = + ;Use the in-tree copy of ESPAsyncWebServer for development + ; symlink://../.. + ;Otherwise use the upstream copy + ESP32Async/ESPAsyncWebServer + ESP32Async/AsyncTCP + +build_flags = + -DCORE_DEBUG_LEVEL=0 + +board_build.partitions = default.csv ; For 4M ESP32; filesystem is 1.4 MB +board_build.filesystem = littlefs From 3d4900bc02f34956f7424ce28eea3c22fe4eaf25 Mon Sep 17 00:00:00 2001 From: Mitch Bradley Date: Sat, 24 Jan 2026 16:02:09 -0800 Subject: [PATCH 4/5] Fixed formatting both in WebDAV.ino and removed redundant map --- examples/WebDAV/WebDAV.ino | 309 ++++++++++++++++++------------------- 1 file changed, 150 insertions(+), 159 deletions(-) diff --git a/examples/WebDAV/WebDAV.ino b/examples/WebDAV/WebDAV.ino index 35b4c9d18..b8b096a8b 100644 --- a/examples/WebDAV/WebDAV.ino +++ b/examples/WebDAV/WebDAV.ino @@ -161,180 +161,171 @@ static const char slash = '/'; // ._* (metadata for the file *) // .hidden - std::map reqNames = { - { 1, "GET" }, { 2, "POST" }, { 4, "DELETE" }, { 8, "PUT" }, { 16, "PATCH" }, - { 32, "HEAD" }, { 64, "OPTIONS" }, { 128, "PROPFIND" }, { 256, "LOCK" }, { 512, "UNLOCK" }, - { 1024, "PROPATCH" }, { 2048, "MKCOL" }, { 4096, "MOVE" }, { 8192, "COPY" }, { 16384, "RESERVED" }, -}; +void WebDAV::handleRequest(AsyncWebServerRequest *request) { + // If request->_tempObject is not null, handleBody already + // did the necessary work for a PUT operation + auto state = static_cast(request->_tempObject); + if (state) { + if (state->outFile) { + // The file was already opened and written in handleBody so + // we are done. We will handle PUT without body data below. + state->outFile.close(); + request->send(201); // Created + } + delete state; + request->_tempObject = nullptr; + return; + } - void WebDAV::handleRequest(AsyncWebServerRequest *request) { - // If request->_tempObject is not null, handleBody already - // did the necessary work for a PUT operation - auto state = static_cast(request->_tempObject); - if (state) { - if (state->outFile) { - // The file was already opened and written in handleBody so - // we are done. We will handle PUT without body data below. - state->outFile.close(); - request->send(201); // Created - } - delete state; - request->_tempObject = nullptr; - return; - } + String path = request->url(); - String path = request->url(); - - Serial.print(reqNames.find(request->method())->second); - Serial.print(" "); - Serial.println(path); - - if (request->method() == HTTP_MKCOL) { - Serial.println("MKCOL"); - // does the file/dir already exist? - int status; - if (_fs.exists(path)) { - // Already exists - // I think there is an "Overwrite: {T,F}" header; we should handle it - request->send(405); - } else { - request->send(_fs.mkdir(path) ? 201 : 405); - } - return; - } - - if (request->method() == HTTP_PUT) { - // This PUT code executes if the body was empty, which - // can happen if the client creates a zero-length file. - // MacOS WebDAVFS does that, then later LOCKs the file - // and issues a subsequent PUT with body contents. - - File file = _fs.open(path, FILE_WRITE, true); - if (file) { - file.close(); - request->send(201); // Created - return; - } - request->send(403); - return; - } + Serial.print(request->methodToString()); + Serial.print(" "); + Serial.println(path); + + if (request->method() == HTTP_MKCOL) { + // does the file/dir already exist? + int status; + if (_fs.exists(path)) { + // Already exists + // I think there is an "Overwrite: {T,F}" header; we should handle it + request->send(405); + } else { + request->send(_fs.mkdir(path) ? 201 : 405); + } + return; + } - if (request->method() == HTTP_OPTIONS) { - AsyncWebServerResponse *response = request->beginResponse(200); - response->addHeader("Dav", "1,2"); - response->addHeader("Ms-Author-Via", "DAV"); - response->addHeader("Allow", "PROPFIND,OPTIONS,DELETE,MOVE,HEAD,POST,PUT,GET"); - request->send(response); - return; - } + if (request->method() == HTTP_PUT) { + // This PUT code executes if the body was empty, which + // can happen if the client creates a zero-length file. + // MacOS WebDAVFS does that, then later LOCKs the file + // and issues a subsequent PUT with body contents. + + File file = _fs.open(path, FILE_WRITE, true); + if (file) { + file.close(); + request->send(201); // Created + return; + } + request->send(403); + return; + } - // If we are not creating the resource it must already exist - if (!_fs.exists(path)) { - Serial.printf("%s does not exist\n", path.c_str()); - request->send(404, "Resource does not exist"); - return; - } + if (request->method() == HTTP_OPTIONS) { + AsyncWebServerResponse *response = request->beginResponse(200); + response->addHeader("Dav", "1,2"); + response->addHeader("Ms-Author-Via", "DAV"); + response->addHeader("Allow", "PROPFIND,OPTIONS,DELETE,MOVE,HEAD,POST,PUT,GET"); + request->send(response); + return; + } - if (request->method() == HTTP_HEAD) { - File file = _fs.open(path); + // If we are not creating the resource it must already exist + if (!_fs.exists(path)) { + Serial.printf("%s does not exist\n", path.c_str()); + request->send(404, "Resource does not exist"); + return; + } - // HEAD is like GET without a body, but with Content-Length - AsyncWebServerResponse *response = request->beginResponse(200, getContentType(path), ""); // AsyncBasicResponse - response->setContentLength(file.size()); + if (request->method() == HTTP_HEAD) { + File file = _fs.open(path); - request->send(response); - return; - } + // HEAD is like GET without a body, but with Content-Length + AsyncWebServerResponse *response = request->beginResponse(200, getContentType(path), ""); // AsyncBasicResponse + response->setContentLength(file.size()); - if (request->method() == HTTP_GET) { - return handleGet(path, request); - } + request->send(response); + return; + } - if (request->method() == HTTP_PROPFIND) { - handlePropfind(path, request); - return; - } + if (request->method() == HTTP_GET) { + return handleGet(path, request); + } - if (request->method() == HTTP_LOCK) { - Serial.println("LOCK"); - - String lockroot("http://"); - lockroot += request->host(); - lockroot += path; - - AsyncResponseStream *response = request->beginResponseStream("application/xml; charset=utf-8"); - response->setCode(200); - response->addHeader("Lock-Token", "urn:uuid:26e57cb3-834d-191a-00de-000042bdecf9"); - - response->print(""); - response->print(""); - response->print(""); - response->print(""); - response->print(""); - response->print(""); - response->print("urn:uuid:26e57cb3-834d-191a-00de-000042bdecf9"); - response->printf("%s", lockroot.c_str()); - response->print("infinity"); - response->printf("%s", "todo"); - response->print("Second-3600"); - response->print(""); - response->print(""); - response->print(""); - - request->send(response); - return; - } - if (request->method() == HTTP_UNLOCK) { - request->send(204); // No Content - return; - } - if (request->method() == HTTP_MOVE) { - const AsyncWebHeader *destinationHeader = request->getHeader("destination"); - if (!destinationHeader || destinationHeader->value().isEmpty()) { - request->send(400, "text/plain", "Missing destination header"); - return; - } + if (request->method() == HTTP_PROPFIND) { + handlePropfind(path, request); + return; + } - // Should handle "Overwrite: {T,F}" header - String newpath = urlToUri(destinationHeader->value()); - Serial.printf("Renaming %s to %s\n", path.c_str(), newpath.c_str()); - - if (_fs.exists(newpath)) { - Serial.printf("Destination file %s already exists\n", newpath.c_str()); - request->send(500, "text/plain", "Destination file exists"); - } else { - if (_fs.rename(path, newpath)) { - Serial.println("Rename succeeded"); - request->send(201); - } else { - Serial.println("Rename failed"); - request->send(500, "text/plain", "Unable to move"); - } - } + if (request->method() == HTTP_LOCK) { + String lockroot("http://"); + lockroot += request->host(); + lockroot += path; + + AsyncResponseStream *response = request->beginResponseStream("application/xml; charset=utf-8"); + response->setCode(200); + response->addHeader("Lock-Token", "urn:uuid:26e57cb3-834d-191a-00de-000042bdecf9"); + + response->print(""); + response->print(""); + response->print(""); + response->print(""); + response->print(""); + response->print(""); + response->print("urn:uuid:26e57cb3-834d-191a-00de-000042bdecf9"); + response->printf("%s", lockroot.c_str()); + response->print("infinity"); + response->printf("%s", "todo"); + response->print("Second-3600"); + response->print(""); + response->print(""); + response->print(""); + + request->send(response); + return; + } + if (request->method() == HTTP_UNLOCK) { + request->send(204); // No Content + return; + } + if (request->method() == HTTP_MOVE) { + const AsyncWebHeader *destinationHeader = request->getHeader("destination"); + if (!destinationHeader || destinationHeader->value().isEmpty()) { + request->send(400, "text/plain", "Missing destination header"); + return; + } - return; - } - if (request->method() == HTTP_DELETE) { - // delete file or dir - bool result; - File file = _fs.open(path); - - if (!file) { - request->send(404); - } else { - if (file.isDirectory()) { - file.close(); - request->send(_fs.rmdir(path) ? 200 : 201); - } else { - file.close(); - request->send(_fs.remove(path) ? 200 : 201); - } - } - return; + // Should handle "Overwrite: {T,F}" header + String newpath = urlToUri(destinationHeader->value()); + Serial.printf("Renaming %s to %s\n", path.c_str(), newpath.c_str()); + + if (_fs.exists(newpath)) { + Serial.printf("Destination file %s already exists\n", newpath.c_str()); + request->send(500, "text/plain", "Destination file exists"); + } else { + if (_fs.rename(path, newpath)) { + Serial.println("Rename succeeded"); + request->send(201); + } else { + Serial.println("Rename failed"); + request->send(500, "text/plain", "Unable to move"); } + } - handleNotFound(request); + return; + } + if (request->method() == HTTP_DELETE) { + // delete file or dir + bool result; + File file = _fs.open(path); + + if (!file) { + request->send(404); + } else { + if (file.isDirectory()) { + file.close(); + request->send(_fs.rmdir(path) ? 200 : 201); + } else { + file.close(); + request->send(_fs.remove(path) ? 200 : 201); + } } + return; + } + + handleNotFound(request); +} void WebDAV::handleBody(AsyncWebServerRequest *request, unsigned char *data, size_t len, size_t index, size_t total) { // The other requests with a body are LOCK and PROPFIND, where the body data is the XML From 07f8a6c754ad794421c3d974022e5f0098dc329f Mon Sep 17 00:00:00 2001 From: Mitch Bradley Date: Sun, 25 Jan 2026 12:01:40 -0800 Subject: [PATCH 5/5] Fixing CI problems --- examples/WebDAV/WebDAV.ino | 307 +++++++++++++++------------------ examples/WebDAV/WiFiConfig.h | 11 -- examples/WebDAV/platformio.ini | 25 --- platformio.ini | 1 + src/ESPAsyncWebServer.h | 3 +- src/WebRequest.cpp | 39 ++--- 6 files changed, 160 insertions(+), 226 deletions(-) delete mode 100644 examples/WebDAV/WiFiConfig.h delete mode 100644 examples/WebDAV/platformio.ini diff --git a/examples/WebDAV/WebDAV.ino b/examples/WebDAV/WebDAV.ino index b8b096a8b..e69966046 100644 --- a/examples/WebDAV/WebDAV.ino +++ b/examples/WebDAV/WebDAV.ino @@ -1,6 +1,5 @@ // SPDX-License-Identifier: LGPL-3.0-or-later -// Copyright 2016-2025 Hristo Gochkov, Mathieu Carbou, Emil Muratov -// Copyright 2026 Mitch Bradley +// Copyright 2016-2026 Hristo Gochkov, Mathieu Carbou, Emil Muratov, Mitch Bradley // // - WebDAV Server to access LittleFS files @@ -11,7 +10,6 @@ #if defined(ESP32) || defined(LIBRETINY) #include #include -#include #elif defined(ESP8266) #include #include @@ -20,65 +18,55 @@ #include #endif -#include - -#include - #include #include using namespace asyncsrv; // Tests: -// Note: The '-4' curl argument prevents long IPv6 mDNS resolution timeouts -// The tests will work without it, but will take several seconds to start. -// If you use the dotted decimal IP address, the -4 is unnecessary. // // Get the OPTIONS that the WebDAV server supports -// curl -4 -v -X OPTIONS http://davtest.local +// curl -v -X OPTIONS http://192.168.4.1 // ** Note: The -v is necessary to see the option values because they // ** in the Allow: response header instead of the body. // List a directory with PROPFIND -// curl -4 -X PROPFIND -H 'Depth: 1' http://davtest.local/ +// curl -X PROPFIND -H 'Depth: 1' http://192.168.4.1/ // List a directory with pretty-printed output -// curl -4 -X PROPFIND -H 'Depth: 1' http://davtest.local/ | xmllint --format - +// curl -X PROPFIND -H 'Depth: 1' http://192.168.4.1/ | xmllint --format - // List a directory showing only the names -// curl -4 -X PROPFIND -H 'Depth: 1' http://davtest.local/ | xmllint --format - | grep href +// curl -X PROPFIND -H 'Depth: 1' http://192.168.4.1/ | xmllint --format - | grep href // Upload a file with PUT -// curl -4 -T myfile.txt http://davtest.local/ +// curl -T myfile.txt http://192.168.4.1/ // Upload a file with PUT using chunked encoding -// curl -4 -T bigfile.txt -H 'Transfer-Encoding: chunked' http://davtest.local/ +// curl -T bigfile.txt -H 'Transfer-Encoding: chunked' http://192.168.4.1/ // ** Note: If the file will not fit in the available space, the server // ** does not know that in advance due to the lack of a Content-Length header. // ** The transfer will proceed until the filesystem fills up, then the transfer -// ** will fail and the partial file will be deleted. +// ** will fail and the partial file will be deleted. This works correctly with +// ** recent versions (e.g. pioarduino) of the arduinoespressif32 framework, but +// ** fails with the stale 3.20017.241212+sha.dcc1105b version due to a LittleFS +// ** bug that has since been fixed. // Immediately reject a chunked PUT that will not fit in available space -// curl -4 -T bigfile.txt -H 'Transfer-Encoding: chunked' -H 'X-Expected-Entity-Length: 99999999' http://davtest.local/ +// curl -T bigfile.txt -H 'Transfer-Encoding: chunked' -H 'X-Expected-Entity-Length: 99999999' http://192.168.4.1/ // ** Note: MacOS WebDAVFS supplies the X-Expected-Entity-Length header with its // ** chunked PUTs // Download a file with GET (result to stdout) -// curl -4 http://davtest.local/myfile.txt +// curl http://192.168.4.1/myfile.txt // Delete a file with DELETE -// curl -4 -X DELETED http://davtest.local/myfile.txt +// curl -X DELETE http://192.168.4.1/myfile.txt // Create a subdirectory with MKCOL -// curl -4 -X MKCOL http://davtest.local/TheSubdir +// curl -X MKCOL http://192.168.4.1/TheSubdir // Upload a file to subdirectory -// curl -4 -T anotherfile.txt http://davtest.local/TheSubdir/ +// curl -T anotherfile.txt http://192.168.4.1/TheSubdir/ // ** Note: without the trailing / the request will fail // Lock a file against other access -// curl -4 -X LOCK http://davtest.local/myfile.txt +// curl -X LOCK http://192.168.4.1/myfile.txt // ** Note: Locks return a token but otherwise do nothing // ** other than making MacOS WebDAVFS happy // Unlock a previously-locked file -// curl -4 -X UNLOCK http://davtest.local/myfile.txt +// curl -X UNLOCK http://192.168.4.1/myfile.txt // Rename oldfile.txt to newfile.txt -// curl -4 -X MOVE -H 'Destination: http://davtest.local/newfile.txt' http://davtest.local/oldfile.txt - -#ifndef ESP32 -// this example is only for the ESP32 -void setup() {} -void loop() {} -#else +// curl -X MOVE -H 'Destination: http://192.168.4.1/newfile.txt' http://192.168.4.1/oldfile.txt struct mime_type { const char *suffix; @@ -147,10 +135,6 @@ bool WebDAV::canHandle(AsyncWebServerRequest *request) const { return request->url().startsWith(_url.c_str()); } -static const char* rootname = "/"; -static const char* slashstr = "/"; -static const char slash = '/'; - // Mac command to prevent .DS_Store files: // defaults write com.apple.desktopservices DSDontWriteNetworkStores -bool TRUE // Mac metadata files: @@ -185,7 +169,6 @@ void WebDAV::handleRequest(AsyncWebServerRequest *request) { if (request->method() == HTTP_MKCOL) { // does the file/dir already exist? - int status; if (_fs.exists(path)) { // Already exists // I think there is an "Overwrite: {T,F}" header; we should handle it @@ -202,7 +185,12 @@ void WebDAV::handleRequest(AsyncWebServerRequest *request) { // MacOS WebDAVFS does that, then later LOCKs the file // and issues a subsequent PUT with body contents. - File file = _fs.open(path, FILE_WRITE, true); +#ifdef ESP32 + File file = _fs.open(path, "w", true); +#else + File file = _fs.open(path, "w"); +#endif + if (file) { file.close(); request->send(201); // Created @@ -229,7 +217,7 @@ void WebDAV::handleRequest(AsyncWebServerRequest *request) { } if (request->method() == HTTP_HEAD) { - File file = _fs.open(path); + File file = _fs.open(path, "r"); // HEAD is like GET without a body, but with Content-Length AsyncWebServerResponse *response = request->beginResponse(200, getContentType(path), ""); // AsyncBasicResponse @@ -280,7 +268,7 @@ void WebDAV::handleRequest(AsyncWebServerRequest *request) { return; } if (request->method() == HTTP_MOVE) { - const AsyncWebHeader *destinationHeader = request->getHeader("destination"); + const AsyncWebHeader *destinationHeader = request->getHeader("Destination"); if (!destinationHeader || destinationHeader->value().isEmpty()) { request->send(400, "text/plain", "Missing destination header"); return; @@ -307,8 +295,7 @@ void WebDAV::handleRequest(AsyncWebServerRequest *request) { } if (request->method() == HTTP_DELETE) { // delete file or dir - bool result; - File file = _fs.open(path); + File file = _fs.open(path, "r"); if (!file) { request->send(404); @@ -327,77 +314,87 @@ void WebDAV::handleRequest(AsyncWebServerRequest *request) { handleNotFound(request); } - void WebDAV::handleBody(AsyncWebServerRequest *request, unsigned char *data, size_t len, size_t index, size_t total) { - // The other requests with a body are LOCK and PROPFIND, where the body data is the XML - // schema for their replies. For now, we just ignore that data and hardcode the reply - // schema. It might be useful to decode the schema to, for example, omit some reply fields, - // but that doesn't appear to be necessary at the moment. - if (request->method() == HTTP_PUT) { - auto state = static_cast(request->_tempObject); - if (index == 0) { - // parse the url to a proper path - String path = request->url(); - - state = new RequestState{File()}; - request->_tempObject = static_cast(state); - - if (total) { - // Ideally this would be _fs.totalBytes() - _fs.usedBytes() - // but the Arduino FS class does not have totalBytes() - // and usedBytes(). They exist in the SDFS and LittleFS - // classes, but with different return types (uint64_t for - // SDFS and uint32_t for LittleFS). - // This should be abstracted somewhere but I have to draw - // the line somewhere with this test code. - size_t avail = LittleFS.totalBytes() - LittleFS.usedBytes(); - avail -= 4096; // Reserve a block for overhead - if (total > avail) { - Serial.printf("PUT %d bytes will not fit in available space (%d).\n", total, avail); - request->send(507); // Insufficient storage - return; - } - } - Serial.print("PUT: Opening "); - Serial.println(path); - - File file = _fs.open(path, FILE_WRITE, true); - if (!file) { - request->send(500); - return; - } - if (file.isDirectory()) { - file.close(); - Serial.println("Cannot PUT to a directory"); - request->send(403); - return; - } - // If we already returned, the File object in request->_tempObject - // is the default-contructed one. The presence of - - std::swap(state->outFile, file); - // Now request->_tempObject contains the actual file object which owns it, - // and default-constructed File() object is in file, which will - // go out of scope - } - if (state && state->outFile) { - Serial.printf("write %d at %d\n", len, index); - auto actual = state->outFile.write(data, len); - if (actual != len) { - Serial.println("WebDAV write failed. Deleting file."); - - // Replace the File object in state with a null one - File file{}; - std::swap(state->outFile, file); - file.close(); - - String path = request->url(); - _fs.remove(path); - request->send(507); // Insufficient storage - return; - } +void WebDAV::handleBody(AsyncWebServerRequest *request, unsigned char *data, size_t len, size_t index, size_t total) { + // The other requests with a body are LOCK and PROPFIND, where the body data is the XML + // schema for their replies. For now, we just ignore that data and hardcode the reply + // schema. It might be useful to decode the schema to, for example, omit some reply fields, + // but that doesn't appear to be necessary at the moment. + if (request->method() == HTTP_PUT) { + auto state = static_cast(request->_tempObject); + if (index == 0) { + // parse the url to a proper path + String path = request->url(); + + state = new RequestState{File()}; + request->_tempObject = static_cast(state); + + if (total) { + // Ideally this would be _fs.totalBytes() - _fs.usedBytes() + // but the Arduino FS class does not have totalBytes() + // and usedBytes(). They exist in the SDFS and LittleFS + // classes, but with different return types (uint64_t for + // SDFS and uint32_t for LittleFS). + // This should be abstracted somewhere but I have to draw + // the line somewhere with this test code. +#ifdef ESP32 + size_t avail = LittleFS.totalBytes() - LittleFS.usedBytes(); +#else + FSInfo info; + _fs.info(info); + auto avail = info.totalBytes - info.usedBytes; +#endif + avail -= 4096; // Reserve a block for overhead + if (total > avail) { + Serial.printf("PUT %d bytes will not fit in available space (%d).\n", total, avail); + request->send(507); // Insufficient storage + return; } } + Serial.print("PUT: Opening "); + Serial.println(path); + +#ifdef ESP32 + File file = _fs.open(path, "w", true); +#else + File file = _fs.open(path, "w"); +#endif + if (!file) { + request->send(500); + return; + } + if (file.isDirectory()) { + file.close(); + Serial.println("Cannot PUT to a directory"); + request->send(403); + return; + } + // If we already returned, the File object in request->_tempObject + // is the default-contructed one. The presence of + + std::swap(state->outFile, file); + // Now request->_tempObject contains the actual file object which owns it, + // and default-constructed File() object is in file, which will + // go out of scope + } + if (state && state->outFile) { + Serial.printf("write %d at %d\n", len, index); + auto actual = state->outFile.write(data, len); + if (actual != len) { + Serial.println("WebDAV write failed. Deleting file."); + + // Replace the File object in state with a null one + File file{}; + std::swap(state->outFile, file); + file.close(); + + String path = request->url(); + _fs.remove(path); + request->send(507); // Insufficient storage + return; + } } + } +} bool WebDAV::acceptsEncoding(AsyncWebServerRequest *request, const char *encoding) { if (request->hasHeader("Accept-Encoding")) { @@ -418,6 +415,7 @@ bool WebDAV::acceptsType(AsyncWebServerRequest *request, const char *type) { void WebDAV::handlePropfind(const String &path, AsyncWebServerRequest *request) { auto depth = 0; + [[maybe_unused]] bool noroot = false; const AsyncWebHeader *depthHeader = request->getHeader("Depth"); @@ -450,7 +448,7 @@ void WebDAV::handlePropfind(const String &path, AsyncWebServerRequest *request) } void WebDAV::handleGet(const String &path, AsyncWebServerRequest *request) { - File file = _fs.open(path); + File file = _fs.open(path, "r"); if (!file) { Serial.printf("%s not found\n", path.c_str()); @@ -459,21 +457,20 @@ void WebDAV::handleGet(const String &path, AsyncWebServerRequest *request) { } AsyncWebServerResponse *response = - request->beginResponse(getContentType(path), file.size(), [file, request](uint8_t *buffer, size_t maxLen, size_t total) mutable -> size_t { + request->beginResponse(getContentType(path), file.size(), [file, request](uint8_t *buffer, size_t maxLen, size_t filled) mutable -> size_t { if (!file) { request->client()->close(); return 0; //RESPONSE_TRY_AGAIN; // This only works for ChunkedResponse } - if (total >= file.size() || request->method() != HTTP_GET) { - file.close(); - return 0; + + int actual = 0; + if (maxLen) { + actual = file.read(buffer, maxLen); } - int bytes = min(file.size(), maxLen); - int actual = file.read(buffer, bytes); // return 0 even when no bytes were loaded - if (bytes == 0 || (bytes + total) >= file.size()) { + if (actual == 0) { file.close(); } - return bytes; + return actual; }); request->onDisconnect([request, file]() mutable { @@ -524,7 +521,7 @@ void WebDAV::sendItem(AsyncResponseStream *response, bool is_dir, const String & } void WebDAV::sendPropResponse(AsyncResponseStream *response, int level, const String &path) { - File file = _fs.open(path); + File file = _fs.open(path, "r"); bool is_dir = file.isDirectory(); size_t size = file.size(); @@ -538,9 +535,17 @@ void WebDAV::sendPropResponse(AsyncResponseStream *response, int level, const St if (is_dir && level--) { String name; +#ifdef ESP32 while ((name = file.getNextFileName()) != "") { sendPropResponse(response, level, name); } +#else + File f; + while ((f = file.openNextFile())) { + sendPropResponse(response, level, f.name()); + f.close(); + } +#endif } } @@ -549,49 +554,29 @@ static AsyncHeaderFreeMiddleware *headerFilter; void setup() { Serial.begin(115200); - Serial.println("Setting up WiFi"); - -#ifdef STA_SSID - Serial.println("Using STA mode"); - WiFi.mode(WIFI_STA); - WiFi.setSleep(false); - WiFi.begin(STA_SSID, STA_PASSWORD); - Serial.print("Connecting to WiFi .."); - - // Wait for connection - while (WiFi.status() != WL_CONNECTED) { - delay(1000); - Serial.print('.'); - } - Serial.println(WiFi.localIP()); -#else - IPAddress local_IP(192, 168, AP_SUBNET, 1); - IPAddress gateway(192, 168, AP_SUBNET, 1); - IPAddress subnet(255, 255, 255, 0); - WiFi.softAPConfig(local_IP, gateway, subnet); +#if ASYNCWEBSERVER_WIFI_SUPPORTED WiFi.mode(WIFI_AP); - WiFi.softAP(AP_SSID); - Serial.print("Starting AP "); - Serial.print(AP_SSID); - Serial.print(" "); - Serial.println(WiFi.softAPIP()); + WiFi.softAP("esp-captive"); #endif -#ifdef MDNS_NAME - // Initialize mDNS - if (!MDNS.begin(MDNS_NAME)) { - Serial.println("Error setting up MDNS responder!"); - while (1) { - delay(1000); - } - } - Serial.printf("mDNS on: %s.local\n", MDNS_NAME); - - // Add service to mDNS - MDNS.addService("http", "tcp", 80); +#ifdef ESP32 + LittleFS.begin(true); + auto total = LittleFS.totalBytes(); + auto used = LittleFS.usedBytes(); +#else + LittleFS.begin(); + FSInfo info; + LittleFS.info(info); + auto total = info.totalBytes; + auto used = info.usedBytes; #endif + Serial.print("LittleFS uses "); + Serial.print(used); + Serial.print(" of "); + Serial.println(total); + headerFilter = new AsyncHeaderFreeMiddleware(); headerFilter->keep("Depth"); @@ -599,22 +584,12 @@ void setup() { server.addMiddlewares({headerFilter}); - LittleFS.begin(true); - Serial.print("LittleFS uses "); - Serial.print(LittleFS.usedBytes()); - Serial.print(" of "); - Serial.println(LittleFS.totalBytes()); - auto flash_dav = new WebDAV("/", LittleFS); server.addHandler(flash_dav); - // server.on("/", HTTP_ANY, handle_roo); - server.begin(); } void loop() { delay(100); } - -#endif diff --git a/examples/WebDAV/WiFiConfig.h b/examples/WebDAV/WiFiConfig.h deleted file mode 100644 index 37a850930..000000000 --- a/examples/WebDAV/WiFiConfig.h +++ /dev/null @@ -1,11 +0,0 @@ -// Define the following to use STA mode - -// #define STA_SSID "MySSID" -// #define STA_PASSWORD "MyPassword" - -// If STA_SSID is not defined AP mode will be used - -#define AP_SSID "esp-captive" -#define AP_SUBNET 4 - -#define MDNS_NAME "davtest" diff --git a/examples/WebDAV/platformio.ini b/examples/WebDAV/platformio.ini deleted file mode 100644 index dc39a547f..000000000 --- a/examples/WebDAV/platformio.ini +++ /dev/null @@ -1,25 +0,0 @@ -[platformio] -src_dir = . - -[env:esp32] - -; As of 2026-01-24, the version of arduino in the platformio registry -; is old with a old buggy version of littlefs. The pioarduino fork is up to date -platform = https://github.com/pioarduino/platform-espressif32.git -board = esp32dev -framework = arduino -monitor_speed = 115200 -monitor_filters=esp32_exception_decoder - -lib_deps = - ;Use the in-tree copy of ESPAsyncWebServer for development - ; symlink://../.. - ;Otherwise use the upstream copy - ESP32Async/ESPAsyncWebServer - ESP32Async/AsyncTCP - -build_flags = - -DCORE_DEBUG_LEVEL=0 - -board_build.partitions = default.csv ; For 4M ESP32; filesystem is 1.4 MB -board_build.filesystem = littlefs diff --git a/platformio.ini b/platformio.ini index 6c294c255..e3fc32f01 100644 --- a/platformio.ini +++ b/platformio.ini @@ -39,6 +39,7 @@ src_dir = examples/PerfTests ; src_dir = examples/URIMatcherTest ; src_dir = examples/WebSocket ; src_dir = examples/WebSocketEasy +; src_dir = examples/WebDAV [env] framework = arduino diff --git a/src/ESPAsyncWebServer.h b/src/ESPAsyncWebServer.h index b4ca964d4..24cc43e3f 100644 --- a/src/ESPAsyncWebServer.h +++ b/src/ESPAsyncWebServer.h @@ -99,8 +99,7 @@ typedef enum { HTTP_MKCOL = 0b0000100000000000, HTTP_MOVE = 0b0001000000000000, HTTP_COPY = 0b0010000000000000, - HTTP_RESERVED = 0b0100000000000000, - HTTP_ANY = 0b0111111111111111, + HTTP_ANY = 0b0011111111111111, } WebRequestMethod; #endif #endif diff --git a/src/WebRequest.cpp b/src/WebRequest.cpp index 832bc4f1e..c1f3148e9 100644 --- a/src/WebRequest.cpp +++ b/src/WebRequest.cpp @@ -41,8 +41,8 @@ AsyncWebServerRequest::AsyncWebServerRequest(AsyncWebServer *s, AsyncClient *c) : _client(c), _server(s), _handler(NULL), _response(NULL), _onDisconnectfn(NULL), _temp(), _parseState(PARSE_REQ_START), _version(0), _method(HTTP_ANY), _url(), _host(), _contentType(), _boundary(), _authorization(), _reqconntype(RCT_HTTP), _authMethod(AsyncAuthType::AUTH_NONE), _isMultipart(false), _isPlainPost(false), _expectingContinue(false), _contentLength(0), _parsedLength(0), _multiParseState(0), _boundaryPosition(0), _itemStartIndex(0), - _itemSize(0), _itemName(), _itemFilename(), _itemType(), _itemValue(), _itemBuffer(0), _itemBufferIndex(0), _itemIsFile(false), _tempObject(NULL), - _chunkedParseState(CHUNK_NONE) { + _itemSize(0), _itemName(), _itemFilename(), _itemType(), _itemValue(), _itemBuffer(0), _itemBufferIndex(0), _itemIsFile(false), + _chunkedParseState(CHUNK_NONE), _tempObject(NULL) { c->onError( [](void *r, AsyncClient *c, int8_t error) { (void)c; @@ -326,23 +326,21 @@ bool AsyncWebServerRequest::_parseReqHead() { _method = HTTP_HEAD; } else if (m == T_OPTIONS) { _method = HTTP_OPTIONS; - } else if(m == "PROPFIND"){ + } else if (m == "PROPFIND") { _method = HTTP_PROPFIND; - } else if(m == "LOCK"){ + } else if (m == "LOCK") { _method = HTTP_LOCK; - } else if(m == "UNLOCK"){ + } else if (m == "UNLOCK") { _method = HTTP_UNLOCK; - } else if(m == "PROPPATCH"){ + } else if (m == "PROPPATCH") { _method = HTTP_PROPPATCH; - } else if(m == "MKCOL"){ + } else if (m == "MKCOL") { _method = HTTP_MKCOL; - } else if(m == "MOVE"){ + } else if (m == "MOVE") { _method = HTTP_MOVE; - } else if(m == "COPY"){ + } else if (m == "COPY") { _method = HTTP_COPY; - } else if(m == "RESERVED"){ - _method = HTTP_RESERVED; - } else if(m == "ANY"){ + } else if (m == "ANY") { _method = HTTP_ANY; } else { return false; @@ -1276,28 +1274,25 @@ const char *AsyncWebServerRequest::methodToString() const { return T_OPTIONS; } if (_method & HTTP_PROPFIND) { - return "PROPFIND"; + return "PROPFIND"; } if (_method & HTTP_LOCK) { - return "LOCK"; + return "LOCK"; } if (_method & HTTP_UNLOCK) { - return "UNLOCK"; + return "UNLOCK"; } if (_method & HTTP_PROPPATCH) { - return "PROPPATCH"; + return "PROPPATCH"; } if (_method & HTTP_MKCOL) { - return "MKCOL"; + return "MKCOL"; } if (_method & HTTP_MOVE) { - return "MOVE"; + return "MOVE"; } if (_method & HTTP_COPY) { - return "COPY"; - } - if (_method & HTTP_RESERVED) { - return "RESERVED"; + return "COPY"; } return T_UNKNOWN; }