feat: upgrade platform and support webdav (#1047)
## Summary - Upgrade platform from espressif32 6.12.0 (Arduino Core 2.0.17) to pioarduino 55.03.37 (Arduino Core 3.3.7, ESP-IDF 5.5.2) - Add WebDAV Class 1 server (RFC 4918) - SD card can be mounted as a network drive - I also slightly fixed the SDK and also made a [pull request ](https://github.com/open-x4-epaper/community-sdk/pull/21) First PR #1030 (was closed because the implementation was based on an old version of the libraries) Issue #439 --------- Co-authored-by: Dave Allie <dave@daveallie.com>
This commit is contained in:
@@ -159,6 +159,12 @@ void CrossPointWebServer::begin() {
|
||||
server->onNotFound([this] { handleNotFound(); });
|
||||
LOG_DBG("WEB", "[MEM] Free heap after route setup: %d bytes", ESP.getFreeHeap());
|
||||
|
||||
// Collect WebDAV headers and register handler
|
||||
const char* davHeaders[] = {"Depth", "Destination", "Overwrite", "If", "Lock-Token", "Timeout"};
|
||||
server->collectHeaders(davHeaders, 6);
|
||||
server->addHandler(&davHandler);
|
||||
LOG_DBG("WEB", "WebDAV handler initialized");
|
||||
|
||||
server->begin();
|
||||
|
||||
// Start WebSocket server for fast binary uploads
|
||||
@@ -502,7 +508,7 @@ void CrossPointWebServer::handleDownload() const {
|
||||
server->sendHeader("Content-Disposition", "attachment; filename=\"" + filename + "\"");
|
||||
server->send(200, contentType.c_str(), "");
|
||||
|
||||
WiFiClient client = server->client();
|
||||
NetworkClient client = server->client();
|
||||
client.write(file);
|
||||
file.close();
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
#include <HalStorage.h>
|
||||
#include <NetworkUdp.h>
|
||||
#include <WebServer.h>
|
||||
#include <WebSocketsServer.h>
|
||||
#include <WiFiUdp.h>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "WebDAVHandler.h"
|
||||
|
||||
// Structure to hold file information
|
||||
struct FileInfo {
|
||||
String name;
|
||||
@@ -71,11 +73,12 @@ class CrossPointWebServer {
|
||||
private:
|
||||
std::unique_ptr<WebServer> server = nullptr;
|
||||
std::unique_ptr<WebSocketsServer> wsServer = nullptr;
|
||||
WebDAVHandler davHandler;
|
||||
bool running = false;
|
||||
bool apMode = false; // true when running in AP mode, false for STA mode
|
||||
uint16_t port = 80;
|
||||
uint16_t wsPort = 81; // WebSocket port
|
||||
WiFiUDP udp;
|
||||
NetworkUDP udp;
|
||||
bool udpActive = false;
|
||||
|
||||
// WebSocket upload state
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
#include <HTTPClient.h>
|
||||
#include <Logging.h>
|
||||
#include <NetworkClient.h>
|
||||
#include <NetworkClientSecure.h>
|
||||
#include <StreamString.h>
|
||||
#include <WiFiClient.h>
|
||||
#include <WiFiClientSecure.h>
|
||||
#include <base64.h>
|
||||
|
||||
#include <cstring>
|
||||
@@ -14,14 +14,14 @@
|
||||
#include "util/UrlUtils.h"
|
||||
|
||||
bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) {
|
||||
// Use WiFiClientSecure for HTTPS, regular WiFiClient for HTTP
|
||||
std::unique_ptr<WiFiClient> client;
|
||||
// Use NetworkClientSecure for HTTPS, regular NetworkClient for HTTP
|
||||
std::unique_ptr<NetworkClient> client;
|
||||
if (UrlUtils::isHttpsUrl(url)) {
|
||||
auto* secureClient = new WiFiClientSecure();
|
||||
auto* secureClient = new NetworkClientSecure();
|
||||
secureClient->setInsecure();
|
||||
client.reset(secureClient);
|
||||
} else {
|
||||
client.reset(new WiFiClient());
|
||||
client.reset(new NetworkClient());
|
||||
}
|
||||
HTTPClient http;
|
||||
|
||||
@@ -64,14 +64,14 @@ bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) {
|
||||
|
||||
HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& url, const std::string& destPath,
|
||||
ProgressCallback progress) {
|
||||
// Use WiFiClientSecure for HTTPS, regular WiFiClient for HTTP
|
||||
std::unique_ptr<WiFiClient> client;
|
||||
// Use NetworkClientSecure for HTTPS, regular NetworkClient for HTTP
|
||||
std::unique_ptr<NetworkClient> client;
|
||||
if (UrlUtils::isHttpsUrl(url)) {
|
||||
auto* secureClient = new WiFiClientSecure();
|
||||
auto* secureClient = new NetworkClientSecure();
|
||||
secureClient->setInsecure();
|
||||
client.reset(secureClient);
|
||||
} else {
|
||||
client.reset(new WiFiClient());
|
||||
client.reset(new NetworkClient());
|
||||
}
|
||||
HTTPClient http;
|
||||
|
||||
@@ -113,7 +113,7 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string&
|
||||
}
|
||||
|
||||
// Get the stream for chunked reading
|
||||
WiFiClient* stream = http.getStreamPtr();
|
||||
NetworkClient* stream = http.getStreamPtr();
|
||||
if (!stream) {
|
||||
LOG_ERR("HTTP", "Failed to get stream");
|
||||
file.close();
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
/**
|
||||
* HTTP client utility for fetching content and downloading files.
|
||||
* Wraps WiFiClientSecure and HTTPClient for HTTPS requests.
|
||||
* Wraps NetworkClientSecure and HTTPClient for HTTPS requests.
|
||||
*/
|
||||
class HttpDownloader {
|
||||
public:
|
||||
|
||||
828
src/network/WebDAVHandler.cpp
Normal file
828
src/network/WebDAVHandler.cpp
Normal file
@@ -0,0 +1,828 @@
|
||||
#include "WebDAVHandler.h"
|
||||
|
||||
#include <Epub.h>
|
||||
#include <FsHelpers.h>
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <esp_task_wdt.h>
|
||||
|
||||
#include "util/StringUtils.h"
|
||||
|
||||
namespace {
|
||||
const char* HIDDEN_ITEMS[] = {"System Volume Information", "XTCache"};
|
||||
constexpr size_t HIDDEN_ITEMS_COUNT = sizeof(HIDDEN_ITEMS) / sizeof(HIDDEN_ITEMS[0]);
|
||||
|
||||
// RFC 1123 date format helper: "Sun, 06 Nov 1994 08:49:37 GMT"
|
||||
// ESP32 doesn't have real-time clock set by default, so we use a fixed epoch date
|
||||
// as a fallback. The date is not critical for WebDAV Class 1 operations.
|
||||
const char* FIXED_DATE = "Thu, 01 Jan 2024 00:00:00 GMT";
|
||||
} // namespace
|
||||
|
||||
// ── RequestHandler interface ─────────────────────────────────────────────────
|
||||
|
||||
bool WebDAVHandler::canHandle(WebServer& server, HTTPMethod method, const String& uri) {
|
||||
(void)server;
|
||||
(void)uri;
|
||||
switch (method) {
|
||||
case HTTP_OPTIONS:
|
||||
case HTTP_PROPFIND:
|
||||
case HTTP_GET:
|
||||
case HTTP_HEAD:
|
||||
case HTTP_PUT:
|
||||
case HTTP_DELETE:
|
||||
case HTTP_MKCOL:
|
||||
case HTTP_MOVE:
|
||||
case HTTP_COPY:
|
||||
case HTTP_LOCK:
|
||||
case HTTP_UNLOCK:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool WebDAVHandler::canRaw(WebServer& server, const String& uri) {
|
||||
(void)uri;
|
||||
return server.method() == HTTP_PUT;
|
||||
}
|
||||
|
||||
void WebDAVHandler::raw(WebServer& server, const String& uri, HTTPRaw& raw) {
|
||||
(void)uri;
|
||||
if (raw.status == RAW_START) {
|
||||
_putPath = getRequestPath(server);
|
||||
if (isProtectedPath(_putPath)) {
|
||||
_putOk = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
int lastSlash = _putPath.lastIndexOf('/');
|
||||
if (lastSlash > 0) {
|
||||
String parentPath = _putPath.substring(0, lastSlash);
|
||||
if (!Storage.exists(parentPath.c_str())) {
|
||||
_putOk = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (_putFile) _putFile.close();
|
||||
_putExisted = Storage.exists(_putPath.c_str());
|
||||
|
||||
if (_putExisted) {
|
||||
FsFile existing = Storage.open(_putPath.c_str());
|
||||
if (existing && existing.isDirectory()) {
|
||||
existing.close();
|
||||
_putOk = false;
|
||||
return;
|
||||
}
|
||||
if (existing) existing.close();
|
||||
}
|
||||
|
||||
// Write to a temp file to avoid destroying the original on failed upload
|
||||
String tempPath = _putPath + ".davtmp";
|
||||
Storage.remove(tempPath.c_str());
|
||||
_putOk = Storage.openFileForWrite("DAV", tempPath, _putFile);
|
||||
LOG_DBG("DAV", "PUT START: %s", _putPath.c_str());
|
||||
|
||||
} else if (raw.status == RAW_WRITE) {
|
||||
if (_putFile && _putOk) {
|
||||
esp_task_wdt_reset();
|
||||
size_t written = _putFile.write(raw.buf, raw.currentSize);
|
||||
if (written != raw.currentSize) {
|
||||
_putOk = false;
|
||||
}
|
||||
}
|
||||
|
||||
} else if (raw.status == RAW_END) {
|
||||
if (_putFile) _putFile.close();
|
||||
if (_putOk) {
|
||||
String tempPath = _putPath + ".davtmp";
|
||||
if (_putExisted) Storage.remove(_putPath.c_str());
|
||||
FsFile tmp = Storage.open(tempPath.c_str());
|
||||
if (tmp) {
|
||||
_putOk = tmp.rename(_putPath.c_str());
|
||||
tmp.close();
|
||||
} else {
|
||||
_putOk = false;
|
||||
}
|
||||
if (!_putOk) Storage.remove(tempPath.c_str());
|
||||
}
|
||||
LOG_DBG("DAV", "PUT END: %u bytes, ok=%d", raw.totalSize, _putOk);
|
||||
|
||||
} else if (raw.status == RAW_ABORTED) {
|
||||
if (_putFile) _putFile.close();
|
||||
String tempPath = _putPath + ".davtmp";
|
||||
Storage.remove(tempPath.c_str());
|
||||
_putOk = false;
|
||||
}
|
||||
}
|
||||
|
||||
bool WebDAVHandler::handle(WebServer& server, HTTPMethod method, const String& uri) {
|
||||
(void)uri;
|
||||
switch (method) {
|
||||
case HTTP_OPTIONS:
|
||||
handleOptions(server);
|
||||
return true;
|
||||
case HTTP_PROPFIND:
|
||||
handlePropfind(server);
|
||||
return true;
|
||||
case HTTP_GET:
|
||||
handleGet(server);
|
||||
return true;
|
||||
case HTTP_HEAD:
|
||||
handleHead(server);
|
||||
return true;
|
||||
case HTTP_PUT:
|
||||
handlePut(server);
|
||||
return true;
|
||||
case HTTP_DELETE:
|
||||
handleDelete(server);
|
||||
return true;
|
||||
case HTTP_MKCOL:
|
||||
handleMkcol(server);
|
||||
return true;
|
||||
case HTTP_MOVE:
|
||||
handleMove(server);
|
||||
return true;
|
||||
case HTTP_COPY:
|
||||
handleCopy(server);
|
||||
return true;
|
||||
case HTTP_LOCK:
|
||||
handleLock(server);
|
||||
return true;
|
||||
case HTTP_UNLOCK:
|
||||
handleUnlock(server);
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── OPTIONS ──────────────────────────────────────────────────────────────────
|
||||
|
||||
void WebDAVHandler::handleOptions(WebServer& s) {
|
||||
s.sendHeader("DAV", "1");
|
||||
s.sendHeader("Allow",
|
||||
"OPTIONS, GET, HEAD, PUT, DELETE, "
|
||||
"PROPFIND, MKCOL, MOVE, COPY, LOCK, UNLOCK");
|
||||
s.sendHeader("MS-Author-Via", "DAV");
|
||||
s.send(200);
|
||||
LOG_DBG("DAV", "OPTIONS %s", s.uri().c_str());
|
||||
}
|
||||
|
||||
// ── PROPFIND ─────────────────────────────────────────────────────────────────
|
||||
|
||||
void WebDAVHandler::handlePropfind(WebServer& s) {
|
||||
String path = getRequestPath(s);
|
||||
int depth = getDepth(s);
|
||||
|
||||
LOG_DBG("DAV", "PROPFIND %s depth=%d", path.c_str(), depth);
|
||||
|
||||
// Check if path exists
|
||||
if (!Storage.exists(path.c_str()) && path != "/") {
|
||||
s.send(404, "text/plain", "Not Found");
|
||||
return;
|
||||
}
|
||||
|
||||
FsFile root = Storage.open(path.c_str());
|
||||
if (!root) {
|
||||
if (path == "/") {
|
||||
// Root should always work — send minimal response
|
||||
s.setContentLength(CONTENT_LENGTH_UNKNOWN);
|
||||
s.send(207, "application/xml; charset=\"utf-8\"", "");
|
||||
s.sendContent(
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
|
||||
"<D:multistatus xmlns:D=\"DAV:\">\n");
|
||||
sendPropEntry(s, "/", true, 0, FIXED_DATE);
|
||||
s.sendContent("</D:multistatus>\n");
|
||||
s.sendContent("");
|
||||
return;
|
||||
}
|
||||
s.send(500, "text/plain", "Failed to open");
|
||||
return;
|
||||
}
|
||||
|
||||
bool isDir = root.isDirectory();
|
||||
|
||||
s.setContentLength(CONTENT_LENGTH_UNKNOWN);
|
||||
s.send(207, "application/xml; charset=\"utf-8\"", "");
|
||||
s.sendContent(
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
|
||||
"<D:multistatus xmlns:D=\"DAV:\">\n");
|
||||
|
||||
// Entry for the resource itself
|
||||
if (isDir) {
|
||||
sendPropEntry(s, path, true, 0, FIXED_DATE);
|
||||
} else {
|
||||
sendPropEntry(s, path, false, root.size(), FIXED_DATE);
|
||||
root.close();
|
||||
s.sendContent("</D:multistatus>\n");
|
||||
s.sendContent("");
|
||||
return;
|
||||
}
|
||||
|
||||
// If depth > 0 and it's a directory, list children
|
||||
if (depth > 0) {
|
||||
FsFile file = root.openNextFile();
|
||||
char name[500];
|
||||
while (file) {
|
||||
file.getName(name, sizeof(name));
|
||||
String fileName(name);
|
||||
|
||||
// Skip hidden/protected items
|
||||
bool shouldHide = fileName.startsWith(".");
|
||||
if (!shouldHide) {
|
||||
for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) {
|
||||
if (fileName.equals(HIDDEN_ITEMS[i])) {
|
||||
shouldHide = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldHide) {
|
||||
String childPath = path;
|
||||
if (!childPath.endsWith("/")) childPath += "/";
|
||||
childPath += fileName;
|
||||
|
||||
if (file.isDirectory()) {
|
||||
sendPropEntry(s, childPath, true, 0, FIXED_DATE);
|
||||
} else {
|
||||
sendPropEntry(s, childPath, false, file.size(), FIXED_DATE);
|
||||
}
|
||||
}
|
||||
|
||||
file.close();
|
||||
yield();
|
||||
esp_task_wdt_reset();
|
||||
file = root.openNextFile();
|
||||
}
|
||||
}
|
||||
|
||||
root.close();
|
||||
s.sendContent("</D:multistatus>\n");
|
||||
s.sendContent("");
|
||||
}
|
||||
|
||||
void WebDAVHandler::sendPropEntry(WebServer& s, const String& path, bool isDir, size_t size,
|
||||
const String& lastModified) const {
|
||||
String href;
|
||||
urlEncodePath(path, href);
|
||||
// Ensure directory hrefs end with /
|
||||
if (isDir && !href.endsWith("/")) href += "/";
|
||||
|
||||
String xml = "<D:response><D:href>";
|
||||
xml += href;
|
||||
xml += "</D:href><D:propstat><D:prop>";
|
||||
|
||||
if (isDir) {
|
||||
xml += "<D:resourcetype><D:collection/></D:resourcetype>";
|
||||
} else {
|
||||
xml += "<D:resourcetype/>";
|
||||
xml += "<D:getcontentlength>";
|
||||
xml += String(size);
|
||||
xml += "</D:getcontentlength>";
|
||||
String mime = getMimeType(path);
|
||||
xml += "<D:getcontenttype>";
|
||||
xml += mime;
|
||||
xml += "</D:getcontenttype>";
|
||||
}
|
||||
|
||||
xml += "<D:getlastmodified>";
|
||||
xml += lastModified;
|
||||
xml += "</D:getlastmodified>";
|
||||
|
||||
xml += "</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>\n";
|
||||
|
||||
s.sendContent(xml);
|
||||
}
|
||||
|
||||
// ── GET ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
void WebDAVHandler::handleGet(WebServer& s) {
|
||||
String path = getRequestPath(s);
|
||||
LOG_DBG("DAV", "GET %s", path.c_str());
|
||||
|
||||
if (isProtectedPath(path)) {
|
||||
s.send(403, "text/plain", "Forbidden");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Storage.exists(path.c_str())) {
|
||||
s.send(404, "text/plain", "Not Found");
|
||||
return;
|
||||
}
|
||||
|
||||
FsFile file = Storage.open(path.c_str());
|
||||
if (!file) {
|
||||
s.send(500, "text/plain", "Failed to open file");
|
||||
return;
|
||||
}
|
||||
if (file.isDirectory()) {
|
||||
file.close();
|
||||
// For directories, return a PROPFIND-like response or redirect
|
||||
s.send(405, "text/plain", "Method Not Allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
String contentType = getMimeType(path);
|
||||
s.setContentLength(file.size());
|
||||
s.send(200, contentType.c_str(), "");
|
||||
|
||||
NetworkClient client = s.client();
|
||||
client.write(file);
|
||||
file.close();
|
||||
}
|
||||
|
||||
// ── HEAD ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
void WebDAVHandler::handleHead(WebServer& s) {
|
||||
String path = getRequestPath(s);
|
||||
LOG_DBG("DAV", "HEAD %s", path.c_str());
|
||||
|
||||
if (isProtectedPath(path)) {
|
||||
s.send(403, "text/plain", "");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Storage.exists(path.c_str())) {
|
||||
s.send(404, "text/plain", "");
|
||||
return;
|
||||
}
|
||||
|
||||
FsFile file = Storage.open(path.c_str());
|
||||
if (!file) {
|
||||
s.send(500, "text/plain", "");
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.isDirectory()) {
|
||||
file.close();
|
||||
s.send(200, "text/html", "");
|
||||
return;
|
||||
}
|
||||
|
||||
String contentType = getMimeType(path);
|
||||
s.setContentLength(file.size());
|
||||
s.send(200, contentType.c_str(), "");
|
||||
file.close();
|
||||
}
|
||||
|
||||
// ── PUT ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
void WebDAVHandler::handlePut(WebServer& s) {
|
||||
// Body was already received via canRaw/raw callbacks
|
||||
String path = getRequestPath(s);
|
||||
LOG_DBG("DAV", "PUT %s", path.c_str());
|
||||
|
||||
if (isProtectedPath(path)) {
|
||||
s.send(403, "text/plain", "Forbidden");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_putOk) {
|
||||
String tempPath = path + ".davtmp";
|
||||
Storage.remove(tempPath.c_str());
|
||||
s.send(500, "text/plain", "Write failed - incomplete upload or disk full");
|
||||
return;
|
||||
}
|
||||
|
||||
clearEpubCacheIfNeeded(path);
|
||||
s.send(_putExisted ? 204 : 201);
|
||||
LOG_DBG("DAV", "PUT complete: %s", path.c_str());
|
||||
}
|
||||
|
||||
// ── DELETE ───────────────────────────────────────────────────────────────────
|
||||
|
||||
void WebDAVHandler::handleDelete(WebServer& s) {
|
||||
String path = getRequestPath(s);
|
||||
LOG_DBG("DAV", "DELETE %s", path.c_str());
|
||||
|
||||
if (path == "/" || path.isEmpty()) {
|
||||
s.send(403, "text/plain", "Cannot delete root");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isProtectedPath(path)) {
|
||||
s.send(403, "text/plain", "Forbidden");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Storage.exists(path.c_str())) {
|
||||
s.send(404, "text/plain", "Not Found");
|
||||
return;
|
||||
}
|
||||
|
||||
FsFile file = Storage.open(path.c_str());
|
||||
if (!file) {
|
||||
s.send(500, "text/plain", "Failed to open");
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.isDirectory()) {
|
||||
// Check if directory is empty
|
||||
FsFile entry = file.openNextFile();
|
||||
if (entry) {
|
||||
entry.close();
|
||||
file.close();
|
||||
s.send(409, "text/plain", "Directory not empty");
|
||||
return;
|
||||
}
|
||||
file.close();
|
||||
if (Storage.rmdir(path.c_str())) {
|
||||
s.send(204);
|
||||
} else {
|
||||
s.send(500, "text/plain", "Failed to remove directory");
|
||||
}
|
||||
} else {
|
||||
file.close();
|
||||
clearEpubCacheIfNeeded(path);
|
||||
if (Storage.remove(path.c_str())) {
|
||||
s.send(204);
|
||||
} else {
|
||||
s.send(500, "text/plain", "Failed to delete file");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── MKCOL ────────────────────────────────────────────────────────────────────
|
||||
|
||||
void WebDAVHandler::handleMkcol(WebServer& s) {
|
||||
String path = getRequestPath(s);
|
||||
LOG_DBG("DAV", "MKCOL %s", path.c_str());
|
||||
|
||||
if (isProtectedPath(path)) {
|
||||
s.send(403, "text/plain", "Forbidden");
|
||||
return;
|
||||
}
|
||||
|
||||
// MKCOL must not have a body (RFC 4918)
|
||||
if (s.clientContentLength() > 0) {
|
||||
s.send(415, "text/plain", "Unsupported Media Type");
|
||||
return;
|
||||
}
|
||||
|
||||
if (Storage.exists(path.c_str())) {
|
||||
s.send(405, "text/plain", "Already exists");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check parent exists
|
||||
int lastSlash = path.lastIndexOf('/');
|
||||
if (lastSlash > 0) {
|
||||
String parentPath = path.substring(0, lastSlash);
|
||||
if (!parentPath.isEmpty() && !Storage.exists(parentPath.c_str())) {
|
||||
s.send(409, "text/plain", "Parent directory does not exist");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (Storage.mkdir(path.c_str())) {
|
||||
s.send(201);
|
||||
LOG_DBG("DAV", "Created directory: %s", path.c_str());
|
||||
} else {
|
||||
s.send(500, "text/plain", "Failed to create directory");
|
||||
}
|
||||
}
|
||||
|
||||
// ── MOVE ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
void WebDAVHandler::handleMove(WebServer& s) {
|
||||
String srcPath = getRequestPath(s);
|
||||
String dstPath = getDestinationPath(s);
|
||||
bool overwrite = getOverwrite(s);
|
||||
|
||||
LOG_DBG("DAV", "MOVE %s -> %s (overwrite=%d)", srcPath.c_str(), dstPath.c_str(), overwrite);
|
||||
|
||||
if (srcPath == "/" || srcPath.isEmpty()) {
|
||||
s.send(403, "text/plain", "Cannot move root");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isProtectedPath(srcPath) || isProtectedPath(dstPath)) {
|
||||
s.send(403, "text/plain", "Forbidden");
|
||||
return;
|
||||
}
|
||||
|
||||
if (dstPath.isEmpty()) {
|
||||
s.send(400, "text/plain", "Missing Destination header");
|
||||
return;
|
||||
}
|
||||
|
||||
if (srcPath == dstPath) {
|
||||
s.send(204);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Storage.exists(srcPath.c_str())) {
|
||||
s.send(404, "text/plain", "Source not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check destination parent exists
|
||||
int lastSlash = dstPath.lastIndexOf('/');
|
||||
if (lastSlash > 0) {
|
||||
String parentPath = dstPath.substring(0, lastSlash);
|
||||
if (!parentPath.isEmpty() && !Storage.exists(parentPath.c_str())) {
|
||||
s.send(409, "text/plain", "Destination parent does not exist");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
bool dstExists = Storage.exists(dstPath.c_str());
|
||||
if (dstExists && !overwrite) {
|
||||
s.send(412, "text/plain", "Destination exists and Overwrite is F");
|
||||
return;
|
||||
}
|
||||
|
||||
if (dstExists) {
|
||||
Storage.remove(dstPath.c_str());
|
||||
}
|
||||
|
||||
FsFile file = Storage.open(srcPath.c_str());
|
||||
if (!file) {
|
||||
s.send(500, "text/plain", "Failed to open source");
|
||||
return;
|
||||
}
|
||||
|
||||
clearEpubCacheIfNeeded(srcPath);
|
||||
bool success = file.rename(dstPath.c_str());
|
||||
file.close();
|
||||
|
||||
if (success) {
|
||||
s.send(dstExists ? 204 : 201);
|
||||
} else {
|
||||
s.send(500, "text/plain", "Move failed");
|
||||
}
|
||||
}
|
||||
|
||||
// ── COPY ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
void WebDAVHandler::handleCopy(WebServer& s) {
|
||||
String srcPath = getRequestPath(s);
|
||||
String dstPath = getDestinationPath(s);
|
||||
bool overwrite = getOverwrite(s);
|
||||
|
||||
LOG_DBG("DAV", "COPY %s -> %s (overwrite=%d)", srcPath.c_str(), dstPath.c_str(), overwrite);
|
||||
|
||||
if (isProtectedPath(srcPath) || isProtectedPath(dstPath)) {
|
||||
s.send(403, "text/plain", "Forbidden");
|
||||
return;
|
||||
}
|
||||
|
||||
if (dstPath.isEmpty()) {
|
||||
s.send(400, "text/plain", "Missing Destination header");
|
||||
return;
|
||||
}
|
||||
|
||||
if (srcPath == dstPath) {
|
||||
s.send(204);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Storage.exists(srcPath.c_str())) {
|
||||
s.send(404, "text/plain", "Source not found");
|
||||
return;
|
||||
}
|
||||
|
||||
FsFile srcFile = Storage.open(srcPath.c_str());
|
||||
if (!srcFile) {
|
||||
s.send(500, "text/plain", "Failed to open source");
|
||||
return;
|
||||
}
|
||||
|
||||
if (srcFile.isDirectory()) {
|
||||
srcFile.close();
|
||||
s.send(403, "text/plain", "Cannot copy directories");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check destination parent exists
|
||||
int lastSlash = dstPath.lastIndexOf('/');
|
||||
if (lastSlash > 0) {
|
||||
String parentPath = dstPath.substring(0, lastSlash);
|
||||
if (!parentPath.isEmpty() && !Storage.exists(parentPath.c_str())) {
|
||||
srcFile.close();
|
||||
s.send(409, "text/plain", "Destination parent does not exist");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
bool dstExists = Storage.exists(dstPath.c_str());
|
||||
if (dstExists && !overwrite) {
|
||||
srcFile.close();
|
||||
s.send(412, "text/plain", "Destination exists and Overwrite is F");
|
||||
return;
|
||||
}
|
||||
|
||||
if (dstExists) {
|
||||
Storage.remove(dstPath.c_str());
|
||||
}
|
||||
|
||||
FsFile dstFile;
|
||||
if (!Storage.openFileForWrite("DAV", dstPath, dstFile)) {
|
||||
srcFile.close();
|
||||
s.send(500, "text/plain", "Failed to create destination");
|
||||
return;
|
||||
}
|
||||
|
||||
// Streaming copy with 4KB buffer on stack
|
||||
uint8_t buf[4096];
|
||||
bool copyOk = true;
|
||||
while (srcFile.available()) {
|
||||
esp_task_wdt_reset();
|
||||
int bytesRead = srcFile.read(buf, sizeof(buf));
|
||||
if (bytesRead <= 0) break;
|
||||
size_t written = dstFile.write(buf, bytesRead);
|
||||
if (written != (size_t)bytesRead) {
|
||||
copyOk = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
srcFile.close();
|
||||
dstFile.close();
|
||||
|
||||
if (copyOk) {
|
||||
s.send(dstExists ? 204 : 201);
|
||||
} else {
|
||||
Storage.remove(dstPath.c_str());
|
||||
s.send(500, "text/plain", "Copy failed - disk full?");
|
||||
}
|
||||
}
|
||||
|
||||
// ── LOCK / UNLOCK (dummy for client compatibility) ───────────────────────────
|
||||
|
||||
void WebDAVHandler::handleLock(WebServer& s) {
|
||||
String path = getRequestPath(s);
|
||||
LOG_DBG("DAV", "LOCK %s (dummy)", path.c_str());
|
||||
|
||||
// Return a dummy lock token for client compatibility
|
||||
String xml =
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"
|
||||
"<D:prop xmlns:D=\"DAV:\">\n"
|
||||
"<D:lockdiscovery><D:activelock>\n"
|
||||
"<D:locktype><D:write/></D:locktype>\n"
|
||||
"<D:lockscope><D:exclusive/></D:lockscope>\n"
|
||||
"<D:depth>infinity</D:depth>\n"
|
||||
"<D:owner><D:href>crosspoint</D:href></D:owner>\n"
|
||||
"<D:timeout>Second-3600</D:timeout>\n"
|
||||
"<D:locktoken><D:href>urn:uuid:dummy-lock-token</D:href></D:locktoken>\n"
|
||||
"<D:lockroot><D:href>/</D:href></D:lockroot>\n"
|
||||
"</D:activelock></D:lockdiscovery>\n"
|
||||
"</D:prop>\n";
|
||||
|
||||
s.sendHeader("Lock-Token", "<urn:uuid:dummy-lock-token>");
|
||||
s.send(200, "application/xml; charset=\"utf-8\"", xml);
|
||||
}
|
||||
|
||||
void WebDAVHandler::handleUnlock(WebServer& s) {
|
||||
LOG_DBG("DAV", "UNLOCK %s (dummy)", s.uri().c_str());
|
||||
s.send(204);
|
||||
}
|
||||
|
||||
// ── Utility functions ────────────────────────────────────────────────────────
|
||||
|
||||
String WebDAVHandler::getRequestPath(WebServer& s) const {
|
||||
String uri = s.uri();
|
||||
String decoded = WebServer::urlDecode(uri);
|
||||
|
||||
// Normalize using FsHelpers
|
||||
std::string normalized = FsHelpers::normalisePath(decoded.c_str());
|
||||
String result = normalized.c_str();
|
||||
|
||||
if (result.isEmpty()) return "/";
|
||||
if (!result.startsWith("/")) result = "/" + result;
|
||||
|
||||
// Remove trailing slash unless root
|
||||
if (result.length() > 1 && result.endsWith("/")) {
|
||||
result = result.substring(0, result.length() - 1);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
String WebDAVHandler::getDestinationPath(WebServer& s) const {
|
||||
String dest = s.header("Destination");
|
||||
if (dest.isEmpty()) return "";
|
||||
|
||||
// Extract path from full URL: http://host/path -> /path
|
||||
// Find the third slash (after http://)
|
||||
int schemeEnd = dest.indexOf("://");
|
||||
if (schemeEnd >= 0) {
|
||||
int pathStart = dest.indexOf('/', schemeEnd + 3);
|
||||
if (pathStart >= 0) {
|
||||
dest = dest.substring(pathStart);
|
||||
} else {
|
||||
dest = "/";
|
||||
}
|
||||
}
|
||||
|
||||
String decoded = WebServer::urlDecode(dest);
|
||||
std::string normalized = FsHelpers::normalisePath(decoded.c_str());
|
||||
String result = normalized.c_str();
|
||||
|
||||
if (result.isEmpty()) return "/";
|
||||
if (!result.startsWith("/")) result = "/" + result;
|
||||
|
||||
// Remove trailing slash unless root
|
||||
if (result.length() > 1 && result.endsWith("/")) {
|
||||
result = result.substring(0, result.length() - 1);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
void WebDAVHandler::urlEncodePath(const String& path, String& out) const {
|
||||
out = "";
|
||||
for (unsigned int i = 0; i < path.length(); i++) {
|
||||
char c = path.charAt(i);
|
||||
if (c == '/') {
|
||||
out += '/';
|
||||
} else if (c == ' ') {
|
||||
out += "%20";
|
||||
} else if (c == '%') {
|
||||
out += "%25";
|
||||
} else if (c == '#') {
|
||||
out += "%23";
|
||||
} else if (c == '?') {
|
||||
out += "%3F";
|
||||
} else if (c == '&') {
|
||||
out += "%26";
|
||||
} else if ((uint8_t)c > 127) {
|
||||
// Encode non-ASCII bytes
|
||||
char hex[4];
|
||||
snprintf(hex, sizeof(hex), "%%%02X", (uint8_t)c);
|
||||
out += hex;
|
||||
} else {
|
||||
out += c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool WebDAVHandler::isProtectedPath(const String& path) const {
|
||||
// Check every segment of the path, not just the last one.
|
||||
// This prevents access to e.g. /.hidden/somefile or /System Volume Information/foo
|
||||
int start = 0;
|
||||
while (start < (int)path.length()) {
|
||||
if (path.charAt(start) == '/') {
|
||||
start++;
|
||||
continue;
|
||||
}
|
||||
int end = path.indexOf('/', start);
|
||||
if (end == -1) end = path.length();
|
||||
|
||||
String segment = path.substring(start, end);
|
||||
|
||||
if (segment.startsWith(".")) return true;
|
||||
|
||||
for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) {
|
||||
if (segment.equals(HIDDEN_ITEMS[i])) return true;
|
||||
}
|
||||
|
||||
start = end + 1;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
int WebDAVHandler::getDepth(WebServer& s) const {
|
||||
String depth = s.header("Depth");
|
||||
if (depth == "0") return 0;
|
||||
if (depth == "1") return 1;
|
||||
// "infinity" or missing → treat as 1 (Class 1 servers don't need to support infinity)
|
||||
return 1;
|
||||
}
|
||||
|
||||
bool WebDAVHandler::getOverwrite(WebServer& s) const {
|
||||
String ow = s.header("Overwrite");
|
||||
if (ow == "F" || ow == "f") return false;
|
||||
return true; // Default is T
|
||||
}
|
||||
|
||||
void WebDAVHandler::clearEpubCacheIfNeeded(const String& path) const {
|
||||
if (StringUtils::checkFileExtension(path, ".epub")) {
|
||||
Epub(path.c_str(), "/.crosspoint").clearCache();
|
||||
LOG_DBG("DAV", "Cleared epub cache for: %s", path.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
String WebDAVHandler::getMimeType(const String& path) const {
|
||||
if (StringUtils::checkFileExtension(path, ".epub")) return "application/epub+zip";
|
||||
if (StringUtils::checkFileExtension(path, ".pdf")) return "application/pdf";
|
||||
if (StringUtils::checkFileExtension(path, ".txt")) return "text/plain";
|
||||
if (StringUtils::checkFileExtension(path, ".html") || StringUtils::checkFileExtension(path, ".htm"))
|
||||
return "text/html";
|
||||
if (StringUtils::checkFileExtension(path, ".css")) return "text/css";
|
||||
if (StringUtils::checkFileExtension(path, ".js")) return "application/javascript";
|
||||
if (StringUtils::checkFileExtension(path, ".json")) return "application/json";
|
||||
if (StringUtils::checkFileExtension(path, ".xml")) return "application/xml";
|
||||
if (StringUtils::checkFileExtension(path, ".jpg") || StringUtils::checkFileExtension(path, ".jpeg"))
|
||||
return "image/jpeg";
|
||||
if (StringUtils::checkFileExtension(path, ".png")) return "image/png";
|
||||
if (StringUtils::checkFileExtension(path, ".gif")) return "image/gif";
|
||||
if (StringUtils::checkFileExtension(path, ".svg")) return "image/svg+xml";
|
||||
if (StringUtils::checkFileExtension(path, ".zip")) return "application/zip";
|
||||
if (StringUtils::checkFileExtension(path, ".gz")) return "application/gzip";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
44
src/network/WebDAVHandler.h
Normal file
44
src/network/WebDAVHandler.h
Normal file
@@ -0,0 +1,44 @@
|
||||
#pragma once
|
||||
|
||||
#include <HalStorage.h>
|
||||
#include <WebServer.h>
|
||||
|
||||
class WebDAVHandler : public RequestHandler {
|
||||
public:
|
||||
// RequestHandler interface
|
||||
bool canHandle(WebServer& server, HTTPMethod method, const String& uri) override;
|
||||
bool canRaw(WebServer& server, const String& uri) override;
|
||||
void raw(WebServer& server, const String& uri, HTTPRaw& raw) override;
|
||||
bool handle(WebServer& server, HTTPMethod method, const String& uri) override;
|
||||
|
||||
private:
|
||||
// PUT streaming state (raw() is called in chunks)
|
||||
FsFile _putFile;
|
||||
String _putPath;
|
||||
bool _putOk = false;
|
||||
bool _putExisted = false;
|
||||
|
||||
// WebDAV method handlers
|
||||
void handleOptions(WebServer& s);
|
||||
void handlePropfind(WebServer& s);
|
||||
void handleGet(WebServer& s);
|
||||
void handleHead(WebServer& s);
|
||||
void handlePut(WebServer& s);
|
||||
void handleDelete(WebServer& s);
|
||||
void handleMkcol(WebServer& s);
|
||||
void handleMove(WebServer& s);
|
||||
void handleCopy(WebServer& s);
|
||||
void handleLock(WebServer& s);
|
||||
void handleUnlock(WebServer& s);
|
||||
|
||||
// Utilities
|
||||
String getRequestPath(WebServer& s) const;
|
||||
String getDestinationPath(WebServer& s) const;
|
||||
void urlEncodePath(const String& path, String& out) const;
|
||||
bool isProtectedPath(const String& path) const;
|
||||
int getDepth(WebServer& s) const;
|
||||
bool getOverwrite(WebServer& s) const;
|
||||
void clearEpubCacheIfNeeded(const String& path) const;
|
||||
void sendPropEntry(WebServer& s, const String& href, bool isDir, size_t size, const String& lastModified) const;
|
||||
String getMimeType(const String& path) const;
|
||||
};
|
||||
Reference in New Issue
Block a user