diff --git a/examples/WebDAV/WebDAV.ino b/examples/WebDAV/WebDAV.ino new file mode 100644 index 00000000..e6996604 --- /dev/null +++ b/examples/WebDAV/WebDAV.ino @@ -0,0 +1,595 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Copyright 2016-2026 Hristo Gochkov, Mathieu Carbou, Emil Muratov, 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 +#elif defined(ESP8266) +#include +#include +#elif defined(TARGET_RP2040) || defined(TARGET_RP2350) || defined(PICO_RP2040) || defined(PICO_RP2350) +#include +#include +#endif + +#include +#include + +using namespace asyncsrv; + +// Tests: +// +// Get the OPTIONS that the WebDAV server supports +// 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 -X PROPFIND -H 'Depth: 1' http://192.168.4.1/ +// List a directory with pretty-printed output +// curl -X PROPFIND -H 'Depth: 1' http://192.168.4.1/ | xmllint --format - +// List a directory showing only the names +// curl -X PROPFIND -H 'Depth: 1' http://192.168.4.1/ | xmllint --format - | grep href +// Upload a file with PUT +// curl -T myfile.txt http://192.168.4.1/ +// Upload a file with PUT using chunked encoding +// 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. 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 -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 http://192.168.4.1/myfile.txt +// Delete a file with DELETE +// curl -X DELETE http://192.168.4.1/myfile.txt +// Create a subdirectory with MKCOL +// curl -X MKCOL http://192.168.4.1/TheSubdir +// Upload a file to subdirectory +// 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 -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 -X UNLOCK http://192.168.4.1/myfile.txt +// Rename oldfile.txt to newfile.txt +// 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; + 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()); +} + +// 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 + +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(request->methodToString()); + Serial.print(" "); + Serial.println(path); + + if (request->method() == HTTP_MKCOL) { + // does the file/dir already exist? + 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. + +#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 + 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, "r"); + + // 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) { + 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 + File file = _fs.open(path, "r"); + + 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. +#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")) { + 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; + + [[maybe_unused]] + 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, "r"); + + 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 filled) mutable -> size_t { + if (!file) { + request->client()->close(); + return 0; //RESPONSE_TRY_AGAIN; // This only works for ChunkedResponse + } + + int actual = 0; + if (maxLen) { + actual = file.read(buffer, maxLen); + } + if (actual == 0) { + file.close(); + } + return actual; + }); + + 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, "r"); + 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; +#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 + } +} + +static AsyncWebServer server(80); +static AsyncHeaderFreeMiddleware *headerFilter; + +void setup() { + Serial.begin(115200); + +#if ASYNCWEBSERVER_WIFI_SUPPORTED + WiFi.mode(WIFI_AP); + WiFi.softAP("esp-captive"); +#endif + +#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"); + headerFilter->keep("Destination"); + + server.addMiddlewares({headerFilter}); + + auto flash_dav = new WebDAV("/", LittleFS); + server.addHandler(flash_dav); + + server.begin(); +} + +void loop() { + delay(100); +} diff --git a/platformio.ini b/platformio.ini index 6c294c25..e3fc32f0 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 80ceb303..24cc43e3 100644 --- a/src/ESPAsyncWebServer.h +++ b/src/ESPAsyncWebServer.h @@ -85,14 +85,21 @@ 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_ANY = 0b0011111111111111, } WebRequestMethod; #endif #endif @@ -114,7 +121,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; /* @@ -280,6 +287,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 261dde12..c1f3148e 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), + _chunkedParseState(CHUNK_NONE), _tempObject(NULL) { 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; @@ -309,6 +326,22 @@ 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 == "ANY") { + _method = HTTP_ANY; } else { return false; } @@ -334,6 +367,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) { @@ -348,7 +453,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; @@ -385,6 +493,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)); } @@ -680,7 +799,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; @@ -1154,6 +1273,27 @@ 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"; + } return T_UNKNOWN; } diff --git a/src/literals.h b/src/literals.h index 52ecb6c2..f1efdadc 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");