A fork of https://github.com/crosspoint-reader/crosspoint-reader
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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>

authored by

Dexif
Dave Allie
and committed by
GitHub
a610568f d9f114b6

+898 -16
+1 -1
platformio.ini
··· 6 6 version = 1.0.0 7 7 8 8 [base] 9 - platform = espressif32 @ 6.12.0 9 + platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.37/platform-espressif32.zip 10 10 board = esp32-c3-devkitm-1 11 11 framework = arduino 12 12 monitor_speed = 115200
+1
src/components/themes/BaseTheme.h
··· 3 3 #include <cstddef> 4 4 #include <cstdint> 5 5 #include <functional> 6 + #include <string> 6 7 #include <vector> 7 8 8 9 class GfxRenderer;
+7 -1
src/network/CrossPointWebServer.cpp
··· 159 159 server->onNotFound([this] { handleNotFound(); }); 160 160 LOG_DBG("WEB", "[MEM] Free heap after route setup: %d bytes", ESP.getFreeHeap()); 161 161 162 + // Collect WebDAV headers and register handler 163 + const char* davHeaders[] = {"Depth", "Destination", "Overwrite", "If", "Lock-Token", "Timeout"}; 164 + server->collectHeaders(davHeaders, 6); 165 + server->addHandler(&davHandler); 166 + LOG_DBG("WEB", "WebDAV handler initialized"); 167 + 162 168 server->begin(); 163 169 164 170 // Start WebSocket server for fast binary uploads ··· 502 508 server->sendHeader("Content-Disposition", "attachment; filename=\"" + filename + "\""); 503 509 server->send(200, contentType.c_str(), ""); 504 510 505 - WiFiClient client = server->client(); 511 + NetworkClient client = server->client(); 506 512 client.write(file); 507 513 file.close(); 508 514 }
+5 -2
src/network/CrossPointWebServer.h
··· 1 1 #pragma once 2 2 3 3 #include <HalStorage.h> 4 + #include <NetworkUdp.h> 4 5 #include <WebServer.h> 5 6 #include <WebSocketsServer.h> 6 - #include <WiFiUdp.h> 7 7 8 8 #include <memory> 9 9 #include <string> 10 10 #include <vector> 11 + 12 + #include "WebDAVHandler.h" 11 13 12 14 // Structure to hold file information 13 15 struct FileInfo { ··· 71 73 private: 72 74 std::unique_ptr<WebServer> server = nullptr; 73 75 std::unique_ptr<WebSocketsServer> wsServer = nullptr; 76 + WebDAVHandler davHandler; 74 77 bool running = false; 75 78 bool apMode = false; // true when running in AP mode, false for STA mode 76 79 uint16_t port = 80; 77 80 uint16_t wsPort = 81; // WebSocket port 78 - WiFiUDP udp; 81 + NetworkUDP udp; 79 82 bool udpActive = false; 80 83 81 84 // WebSocket upload state
+11 -11
src/network/HttpDownloader.cpp
··· 2 2 3 3 #include <HTTPClient.h> 4 4 #include <Logging.h> 5 + #include <NetworkClient.h> 6 + #include <NetworkClientSecure.h> 5 7 #include <StreamString.h> 6 - #include <WiFiClient.h> 7 - #include <WiFiClientSecure.h> 8 8 #include <base64.h> 9 9 10 10 #include <cstring> ··· 14 14 #include "util/UrlUtils.h" 15 15 16 16 bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) { 17 - // Use WiFiClientSecure for HTTPS, regular WiFiClient for HTTP 18 - std::unique_ptr<WiFiClient> client; 17 + // Use NetworkClientSecure for HTTPS, regular NetworkClient for HTTP 18 + std::unique_ptr<NetworkClient> client; 19 19 if (UrlUtils::isHttpsUrl(url)) { 20 - auto* secureClient = new WiFiClientSecure(); 20 + auto* secureClient = new NetworkClientSecure(); 21 21 secureClient->setInsecure(); 22 22 client.reset(secureClient); 23 23 } else { 24 - client.reset(new WiFiClient()); 24 + client.reset(new NetworkClient()); 25 25 } 26 26 HTTPClient http; 27 27 ··· 64 64 65 65 HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& url, const std::string& destPath, 66 66 ProgressCallback progress) { 67 - // Use WiFiClientSecure for HTTPS, regular WiFiClient for HTTP 68 - std::unique_ptr<WiFiClient> client; 67 + // Use NetworkClientSecure for HTTPS, regular NetworkClient for HTTP 68 + std::unique_ptr<NetworkClient> client; 69 69 if (UrlUtils::isHttpsUrl(url)) { 70 - auto* secureClient = new WiFiClientSecure(); 70 + auto* secureClient = new NetworkClientSecure(); 71 71 secureClient->setInsecure(); 72 72 client.reset(secureClient); 73 73 } else { 74 - client.reset(new WiFiClient()); 74 + client.reset(new NetworkClient()); 75 75 } 76 76 HTTPClient http; 77 77 ··· 113 113 } 114 114 115 115 // Get the stream for chunked reading 116 - WiFiClient* stream = http.getStreamPtr(); 116 + NetworkClient* stream = http.getStreamPtr(); 117 117 if (!stream) { 118 118 LOG_ERR("HTTP", "Failed to get stream"); 119 119 file.close();
+1 -1
src/network/HttpDownloader.h
··· 6 6 7 7 /** 8 8 * HTTP client utility for fetching content and downloading files. 9 - * Wraps WiFiClientSecure and HTTPClient for HTTPS requests. 9 + * Wraps NetworkClientSecure and HTTPClient for HTTPS requests. 10 10 */ 11 11 class HttpDownloader { 12 12 public:
+828
src/network/WebDAVHandler.cpp
··· 1 + #include "WebDAVHandler.h" 2 + 3 + #include <Epub.h> 4 + #include <FsHelpers.h> 5 + #include <HalStorage.h> 6 + #include <Logging.h> 7 + #include <esp_task_wdt.h> 8 + 9 + #include "util/StringUtils.h" 10 + 11 + namespace { 12 + const char* HIDDEN_ITEMS[] = {"System Volume Information", "XTCache"}; 13 + constexpr size_t HIDDEN_ITEMS_COUNT = sizeof(HIDDEN_ITEMS) / sizeof(HIDDEN_ITEMS[0]); 14 + 15 + // RFC 1123 date format helper: "Sun, 06 Nov 1994 08:49:37 GMT" 16 + // ESP32 doesn't have real-time clock set by default, so we use a fixed epoch date 17 + // as a fallback. The date is not critical for WebDAV Class 1 operations. 18 + const char* FIXED_DATE = "Thu, 01 Jan 2024 00:00:00 GMT"; 19 + } // namespace 20 + 21 + // ── RequestHandler interface ───────────────────────────────────────────────── 22 + 23 + bool WebDAVHandler::canHandle(WebServer& server, HTTPMethod method, const String& uri) { 24 + (void)server; 25 + (void)uri; 26 + switch (method) { 27 + case HTTP_OPTIONS: 28 + case HTTP_PROPFIND: 29 + case HTTP_GET: 30 + case HTTP_HEAD: 31 + case HTTP_PUT: 32 + case HTTP_DELETE: 33 + case HTTP_MKCOL: 34 + case HTTP_MOVE: 35 + case HTTP_COPY: 36 + case HTTP_LOCK: 37 + case HTTP_UNLOCK: 38 + return true; 39 + default: 40 + return false; 41 + } 42 + } 43 + 44 + bool WebDAVHandler::canRaw(WebServer& server, const String& uri) { 45 + (void)uri; 46 + return server.method() == HTTP_PUT; 47 + } 48 + 49 + void WebDAVHandler::raw(WebServer& server, const String& uri, HTTPRaw& raw) { 50 + (void)uri; 51 + if (raw.status == RAW_START) { 52 + _putPath = getRequestPath(server); 53 + if (isProtectedPath(_putPath)) { 54 + _putOk = false; 55 + return; 56 + } 57 + 58 + // Ensure parent directory exists 59 + int lastSlash = _putPath.lastIndexOf('/'); 60 + if (lastSlash > 0) { 61 + String parentPath = _putPath.substring(0, lastSlash); 62 + if (!Storage.exists(parentPath.c_str())) { 63 + _putOk = false; 64 + return; 65 + } 66 + } 67 + 68 + if (_putFile) _putFile.close(); 69 + _putExisted = Storage.exists(_putPath.c_str()); 70 + 71 + if (_putExisted) { 72 + FsFile existing = Storage.open(_putPath.c_str()); 73 + if (existing && existing.isDirectory()) { 74 + existing.close(); 75 + _putOk = false; 76 + return; 77 + } 78 + if (existing) existing.close(); 79 + } 80 + 81 + // Write to a temp file to avoid destroying the original on failed upload 82 + String tempPath = _putPath + ".davtmp"; 83 + Storage.remove(tempPath.c_str()); 84 + _putOk = Storage.openFileForWrite("DAV", tempPath, _putFile); 85 + LOG_DBG("DAV", "PUT START: %s", _putPath.c_str()); 86 + 87 + } else if (raw.status == RAW_WRITE) { 88 + if (_putFile && _putOk) { 89 + esp_task_wdt_reset(); 90 + size_t written = _putFile.write(raw.buf, raw.currentSize); 91 + if (written != raw.currentSize) { 92 + _putOk = false; 93 + } 94 + } 95 + 96 + } else if (raw.status == RAW_END) { 97 + if (_putFile) _putFile.close(); 98 + if (_putOk) { 99 + String tempPath = _putPath + ".davtmp"; 100 + if (_putExisted) Storage.remove(_putPath.c_str()); 101 + FsFile tmp = Storage.open(tempPath.c_str()); 102 + if (tmp) { 103 + _putOk = tmp.rename(_putPath.c_str()); 104 + tmp.close(); 105 + } else { 106 + _putOk = false; 107 + } 108 + if (!_putOk) Storage.remove(tempPath.c_str()); 109 + } 110 + LOG_DBG("DAV", "PUT END: %u bytes, ok=%d", raw.totalSize, _putOk); 111 + 112 + } else if (raw.status == RAW_ABORTED) { 113 + if (_putFile) _putFile.close(); 114 + String tempPath = _putPath + ".davtmp"; 115 + Storage.remove(tempPath.c_str()); 116 + _putOk = false; 117 + } 118 + } 119 + 120 + bool WebDAVHandler::handle(WebServer& server, HTTPMethod method, const String& uri) { 121 + (void)uri; 122 + switch (method) { 123 + case HTTP_OPTIONS: 124 + handleOptions(server); 125 + return true; 126 + case HTTP_PROPFIND: 127 + handlePropfind(server); 128 + return true; 129 + case HTTP_GET: 130 + handleGet(server); 131 + return true; 132 + case HTTP_HEAD: 133 + handleHead(server); 134 + return true; 135 + case HTTP_PUT: 136 + handlePut(server); 137 + return true; 138 + case HTTP_DELETE: 139 + handleDelete(server); 140 + return true; 141 + case HTTP_MKCOL: 142 + handleMkcol(server); 143 + return true; 144 + case HTTP_MOVE: 145 + handleMove(server); 146 + return true; 147 + case HTTP_COPY: 148 + handleCopy(server); 149 + return true; 150 + case HTTP_LOCK: 151 + handleLock(server); 152 + return true; 153 + case HTTP_UNLOCK: 154 + handleUnlock(server); 155 + return true; 156 + default: 157 + return false; 158 + } 159 + } 160 + 161 + // ── OPTIONS ────────────────────────────────────────────────────────────────── 162 + 163 + void WebDAVHandler::handleOptions(WebServer& s) { 164 + s.sendHeader("DAV", "1"); 165 + s.sendHeader("Allow", 166 + "OPTIONS, GET, HEAD, PUT, DELETE, " 167 + "PROPFIND, MKCOL, MOVE, COPY, LOCK, UNLOCK"); 168 + s.sendHeader("MS-Author-Via", "DAV"); 169 + s.send(200); 170 + LOG_DBG("DAV", "OPTIONS %s", s.uri().c_str()); 171 + } 172 + 173 + // ── PROPFIND ───────────────────────────────────────────────────────────────── 174 + 175 + void WebDAVHandler::handlePropfind(WebServer& s) { 176 + String path = getRequestPath(s); 177 + int depth = getDepth(s); 178 + 179 + LOG_DBG("DAV", "PROPFIND %s depth=%d", path.c_str(), depth); 180 + 181 + // Check if path exists 182 + if (!Storage.exists(path.c_str()) && path != "/") { 183 + s.send(404, "text/plain", "Not Found"); 184 + return; 185 + } 186 + 187 + FsFile root = Storage.open(path.c_str()); 188 + if (!root) { 189 + if (path == "/") { 190 + // Root should always work — send minimal response 191 + s.setContentLength(CONTENT_LENGTH_UNKNOWN); 192 + s.send(207, "application/xml; charset=\"utf-8\"", ""); 193 + s.sendContent( 194 + "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" 195 + "<D:multistatus xmlns:D=\"DAV:\">\n"); 196 + sendPropEntry(s, "/", true, 0, FIXED_DATE); 197 + s.sendContent("</D:multistatus>\n"); 198 + s.sendContent(""); 199 + return; 200 + } 201 + s.send(500, "text/plain", "Failed to open"); 202 + return; 203 + } 204 + 205 + bool isDir = root.isDirectory(); 206 + 207 + s.setContentLength(CONTENT_LENGTH_UNKNOWN); 208 + s.send(207, "application/xml; charset=\"utf-8\"", ""); 209 + s.sendContent( 210 + "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" 211 + "<D:multistatus xmlns:D=\"DAV:\">\n"); 212 + 213 + // Entry for the resource itself 214 + if (isDir) { 215 + sendPropEntry(s, path, true, 0, FIXED_DATE); 216 + } else { 217 + sendPropEntry(s, path, false, root.size(), FIXED_DATE); 218 + root.close(); 219 + s.sendContent("</D:multistatus>\n"); 220 + s.sendContent(""); 221 + return; 222 + } 223 + 224 + // If depth > 0 and it's a directory, list children 225 + if (depth > 0) { 226 + FsFile file = root.openNextFile(); 227 + char name[500]; 228 + while (file) { 229 + file.getName(name, sizeof(name)); 230 + String fileName(name); 231 + 232 + // Skip hidden/protected items 233 + bool shouldHide = fileName.startsWith("."); 234 + if (!shouldHide) { 235 + for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) { 236 + if (fileName.equals(HIDDEN_ITEMS[i])) { 237 + shouldHide = true; 238 + break; 239 + } 240 + } 241 + } 242 + 243 + if (!shouldHide) { 244 + String childPath = path; 245 + if (!childPath.endsWith("/")) childPath += "/"; 246 + childPath += fileName; 247 + 248 + if (file.isDirectory()) { 249 + sendPropEntry(s, childPath, true, 0, FIXED_DATE); 250 + } else { 251 + sendPropEntry(s, childPath, false, file.size(), FIXED_DATE); 252 + } 253 + } 254 + 255 + file.close(); 256 + yield(); 257 + esp_task_wdt_reset(); 258 + file = root.openNextFile(); 259 + } 260 + } 261 + 262 + root.close(); 263 + s.sendContent("</D:multistatus>\n"); 264 + s.sendContent(""); 265 + } 266 + 267 + void WebDAVHandler::sendPropEntry(WebServer& s, const String& path, bool isDir, size_t size, 268 + const String& lastModified) const { 269 + String href; 270 + urlEncodePath(path, href); 271 + // Ensure directory hrefs end with / 272 + if (isDir && !href.endsWith("/")) href += "/"; 273 + 274 + String xml = "<D:response><D:href>"; 275 + xml += href; 276 + xml += "</D:href><D:propstat><D:prop>"; 277 + 278 + if (isDir) { 279 + xml += "<D:resourcetype><D:collection/></D:resourcetype>"; 280 + } else { 281 + xml += "<D:resourcetype/>"; 282 + xml += "<D:getcontentlength>"; 283 + xml += String(size); 284 + xml += "</D:getcontentlength>"; 285 + String mime = getMimeType(path); 286 + xml += "<D:getcontenttype>"; 287 + xml += mime; 288 + xml += "</D:getcontenttype>"; 289 + } 290 + 291 + xml += "<D:getlastmodified>"; 292 + xml += lastModified; 293 + xml += "</D:getlastmodified>"; 294 + 295 + xml += "</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>\n"; 296 + 297 + s.sendContent(xml); 298 + } 299 + 300 + // ── GET ────────────────────────────────────────────────────────────────────── 301 + 302 + void WebDAVHandler::handleGet(WebServer& s) { 303 + String path = getRequestPath(s); 304 + LOG_DBG("DAV", "GET %s", path.c_str()); 305 + 306 + if (isProtectedPath(path)) { 307 + s.send(403, "text/plain", "Forbidden"); 308 + return; 309 + } 310 + 311 + if (!Storage.exists(path.c_str())) { 312 + s.send(404, "text/plain", "Not Found"); 313 + return; 314 + } 315 + 316 + FsFile file = Storage.open(path.c_str()); 317 + if (!file) { 318 + s.send(500, "text/plain", "Failed to open file"); 319 + return; 320 + } 321 + if (file.isDirectory()) { 322 + file.close(); 323 + // For directories, return a PROPFIND-like response or redirect 324 + s.send(405, "text/plain", "Method Not Allowed"); 325 + return; 326 + } 327 + 328 + String contentType = getMimeType(path); 329 + s.setContentLength(file.size()); 330 + s.send(200, contentType.c_str(), ""); 331 + 332 + NetworkClient client = s.client(); 333 + client.write(file); 334 + file.close(); 335 + } 336 + 337 + // ── HEAD ───────────────────────────────────────────────────────────────────── 338 + 339 + void WebDAVHandler::handleHead(WebServer& s) { 340 + String path = getRequestPath(s); 341 + LOG_DBG("DAV", "HEAD %s", path.c_str()); 342 + 343 + if (isProtectedPath(path)) { 344 + s.send(403, "text/plain", ""); 345 + return; 346 + } 347 + 348 + if (!Storage.exists(path.c_str())) { 349 + s.send(404, "text/plain", ""); 350 + return; 351 + } 352 + 353 + FsFile file = Storage.open(path.c_str()); 354 + if (!file) { 355 + s.send(500, "text/plain", ""); 356 + return; 357 + } 358 + 359 + if (file.isDirectory()) { 360 + file.close(); 361 + s.send(200, "text/html", ""); 362 + return; 363 + } 364 + 365 + String contentType = getMimeType(path); 366 + s.setContentLength(file.size()); 367 + s.send(200, contentType.c_str(), ""); 368 + file.close(); 369 + } 370 + 371 + // ── PUT ────────────────────────────────────────────────────────────────────── 372 + 373 + void WebDAVHandler::handlePut(WebServer& s) { 374 + // Body was already received via canRaw/raw callbacks 375 + String path = getRequestPath(s); 376 + LOG_DBG("DAV", "PUT %s", path.c_str()); 377 + 378 + if (isProtectedPath(path)) { 379 + s.send(403, "text/plain", "Forbidden"); 380 + return; 381 + } 382 + 383 + if (!_putOk) { 384 + String tempPath = path + ".davtmp"; 385 + Storage.remove(tempPath.c_str()); 386 + s.send(500, "text/plain", "Write failed - incomplete upload or disk full"); 387 + return; 388 + } 389 + 390 + clearEpubCacheIfNeeded(path); 391 + s.send(_putExisted ? 204 : 201); 392 + LOG_DBG("DAV", "PUT complete: %s", path.c_str()); 393 + } 394 + 395 + // ── DELETE ─────────────────────────────────────────────────────────────────── 396 + 397 + void WebDAVHandler::handleDelete(WebServer& s) { 398 + String path = getRequestPath(s); 399 + LOG_DBG("DAV", "DELETE %s", path.c_str()); 400 + 401 + if (path == "/" || path.isEmpty()) { 402 + s.send(403, "text/plain", "Cannot delete root"); 403 + return; 404 + } 405 + 406 + if (isProtectedPath(path)) { 407 + s.send(403, "text/plain", "Forbidden"); 408 + return; 409 + } 410 + 411 + if (!Storage.exists(path.c_str())) { 412 + s.send(404, "text/plain", "Not Found"); 413 + return; 414 + } 415 + 416 + FsFile file = Storage.open(path.c_str()); 417 + if (!file) { 418 + s.send(500, "text/plain", "Failed to open"); 419 + return; 420 + } 421 + 422 + if (file.isDirectory()) { 423 + // Check if directory is empty 424 + FsFile entry = file.openNextFile(); 425 + if (entry) { 426 + entry.close(); 427 + file.close(); 428 + s.send(409, "text/plain", "Directory not empty"); 429 + return; 430 + } 431 + file.close(); 432 + if (Storage.rmdir(path.c_str())) { 433 + s.send(204); 434 + } else { 435 + s.send(500, "text/plain", "Failed to remove directory"); 436 + } 437 + } else { 438 + file.close(); 439 + clearEpubCacheIfNeeded(path); 440 + if (Storage.remove(path.c_str())) { 441 + s.send(204); 442 + } else { 443 + s.send(500, "text/plain", "Failed to delete file"); 444 + } 445 + } 446 + } 447 + 448 + // ── MKCOL ──────────────────────────────────────────────────────────────────── 449 + 450 + void WebDAVHandler::handleMkcol(WebServer& s) { 451 + String path = getRequestPath(s); 452 + LOG_DBG("DAV", "MKCOL %s", path.c_str()); 453 + 454 + if (isProtectedPath(path)) { 455 + s.send(403, "text/plain", "Forbidden"); 456 + return; 457 + } 458 + 459 + // MKCOL must not have a body (RFC 4918) 460 + if (s.clientContentLength() > 0) { 461 + s.send(415, "text/plain", "Unsupported Media Type"); 462 + return; 463 + } 464 + 465 + if (Storage.exists(path.c_str())) { 466 + s.send(405, "text/plain", "Already exists"); 467 + return; 468 + } 469 + 470 + // Check parent exists 471 + int lastSlash = path.lastIndexOf('/'); 472 + if (lastSlash > 0) { 473 + String parentPath = path.substring(0, lastSlash); 474 + if (!parentPath.isEmpty() && !Storage.exists(parentPath.c_str())) { 475 + s.send(409, "text/plain", "Parent directory does not exist"); 476 + return; 477 + } 478 + } 479 + 480 + if (Storage.mkdir(path.c_str())) { 481 + s.send(201); 482 + LOG_DBG("DAV", "Created directory: %s", path.c_str()); 483 + } else { 484 + s.send(500, "text/plain", "Failed to create directory"); 485 + } 486 + } 487 + 488 + // ── MOVE ───────────────────────────────────────────────────────────────────── 489 + 490 + void WebDAVHandler::handleMove(WebServer& s) { 491 + String srcPath = getRequestPath(s); 492 + String dstPath = getDestinationPath(s); 493 + bool overwrite = getOverwrite(s); 494 + 495 + LOG_DBG("DAV", "MOVE %s -> %s (overwrite=%d)", srcPath.c_str(), dstPath.c_str(), overwrite); 496 + 497 + if (srcPath == "/" || srcPath.isEmpty()) { 498 + s.send(403, "text/plain", "Cannot move root"); 499 + return; 500 + } 501 + 502 + if (isProtectedPath(srcPath) || isProtectedPath(dstPath)) { 503 + s.send(403, "text/plain", "Forbidden"); 504 + return; 505 + } 506 + 507 + if (dstPath.isEmpty()) { 508 + s.send(400, "text/plain", "Missing Destination header"); 509 + return; 510 + } 511 + 512 + if (srcPath == dstPath) { 513 + s.send(204); 514 + return; 515 + } 516 + 517 + if (!Storage.exists(srcPath.c_str())) { 518 + s.send(404, "text/plain", "Source not found"); 519 + return; 520 + } 521 + 522 + // Check destination parent exists 523 + int lastSlash = dstPath.lastIndexOf('/'); 524 + if (lastSlash > 0) { 525 + String parentPath = dstPath.substring(0, lastSlash); 526 + if (!parentPath.isEmpty() && !Storage.exists(parentPath.c_str())) { 527 + s.send(409, "text/plain", "Destination parent does not exist"); 528 + return; 529 + } 530 + } 531 + 532 + bool dstExists = Storage.exists(dstPath.c_str()); 533 + if (dstExists && !overwrite) { 534 + s.send(412, "text/plain", "Destination exists and Overwrite is F"); 535 + return; 536 + } 537 + 538 + if (dstExists) { 539 + Storage.remove(dstPath.c_str()); 540 + } 541 + 542 + FsFile file = Storage.open(srcPath.c_str()); 543 + if (!file) { 544 + s.send(500, "text/plain", "Failed to open source"); 545 + return; 546 + } 547 + 548 + clearEpubCacheIfNeeded(srcPath); 549 + bool success = file.rename(dstPath.c_str()); 550 + file.close(); 551 + 552 + if (success) { 553 + s.send(dstExists ? 204 : 201); 554 + } else { 555 + s.send(500, "text/plain", "Move failed"); 556 + } 557 + } 558 + 559 + // ── COPY ───────────────────────────────────────────────────────────────────── 560 + 561 + void WebDAVHandler::handleCopy(WebServer& s) { 562 + String srcPath = getRequestPath(s); 563 + String dstPath = getDestinationPath(s); 564 + bool overwrite = getOverwrite(s); 565 + 566 + LOG_DBG("DAV", "COPY %s -> %s (overwrite=%d)", srcPath.c_str(), dstPath.c_str(), overwrite); 567 + 568 + if (isProtectedPath(srcPath) || isProtectedPath(dstPath)) { 569 + s.send(403, "text/plain", "Forbidden"); 570 + return; 571 + } 572 + 573 + if (dstPath.isEmpty()) { 574 + s.send(400, "text/plain", "Missing Destination header"); 575 + return; 576 + } 577 + 578 + if (srcPath == dstPath) { 579 + s.send(204); 580 + return; 581 + } 582 + 583 + if (!Storage.exists(srcPath.c_str())) { 584 + s.send(404, "text/plain", "Source not found"); 585 + return; 586 + } 587 + 588 + FsFile srcFile = Storage.open(srcPath.c_str()); 589 + if (!srcFile) { 590 + s.send(500, "text/plain", "Failed to open source"); 591 + return; 592 + } 593 + 594 + if (srcFile.isDirectory()) { 595 + srcFile.close(); 596 + s.send(403, "text/plain", "Cannot copy directories"); 597 + return; 598 + } 599 + 600 + // Check destination parent exists 601 + int lastSlash = dstPath.lastIndexOf('/'); 602 + if (lastSlash > 0) { 603 + String parentPath = dstPath.substring(0, lastSlash); 604 + if (!parentPath.isEmpty() && !Storage.exists(parentPath.c_str())) { 605 + srcFile.close(); 606 + s.send(409, "text/plain", "Destination parent does not exist"); 607 + return; 608 + } 609 + } 610 + 611 + bool dstExists = Storage.exists(dstPath.c_str()); 612 + if (dstExists && !overwrite) { 613 + srcFile.close(); 614 + s.send(412, "text/plain", "Destination exists and Overwrite is F"); 615 + return; 616 + } 617 + 618 + if (dstExists) { 619 + Storage.remove(dstPath.c_str()); 620 + } 621 + 622 + FsFile dstFile; 623 + if (!Storage.openFileForWrite("DAV", dstPath, dstFile)) { 624 + srcFile.close(); 625 + s.send(500, "text/plain", "Failed to create destination"); 626 + return; 627 + } 628 + 629 + // Streaming copy with 4KB buffer on stack 630 + uint8_t buf[4096]; 631 + bool copyOk = true; 632 + while (srcFile.available()) { 633 + esp_task_wdt_reset(); 634 + int bytesRead = srcFile.read(buf, sizeof(buf)); 635 + if (bytesRead <= 0) break; 636 + size_t written = dstFile.write(buf, bytesRead); 637 + if (written != (size_t)bytesRead) { 638 + copyOk = false; 639 + break; 640 + } 641 + } 642 + 643 + srcFile.close(); 644 + dstFile.close(); 645 + 646 + if (copyOk) { 647 + s.send(dstExists ? 204 : 201); 648 + } else { 649 + Storage.remove(dstPath.c_str()); 650 + s.send(500, "text/plain", "Copy failed - disk full?"); 651 + } 652 + } 653 + 654 + // ── LOCK / UNLOCK (dummy for client compatibility) ─────────────────────────── 655 + 656 + void WebDAVHandler::handleLock(WebServer& s) { 657 + String path = getRequestPath(s); 658 + LOG_DBG("DAV", "LOCK %s (dummy)", path.c_str()); 659 + 660 + // Return a dummy lock token for client compatibility 661 + String xml = 662 + "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" 663 + "<D:prop xmlns:D=\"DAV:\">\n" 664 + "<D:lockdiscovery><D:activelock>\n" 665 + "<D:locktype><D:write/></D:locktype>\n" 666 + "<D:lockscope><D:exclusive/></D:lockscope>\n" 667 + "<D:depth>infinity</D:depth>\n" 668 + "<D:owner><D:href>crosspoint</D:href></D:owner>\n" 669 + "<D:timeout>Second-3600</D:timeout>\n" 670 + "<D:locktoken><D:href>urn:uuid:dummy-lock-token</D:href></D:locktoken>\n" 671 + "<D:lockroot><D:href>/</D:href></D:lockroot>\n" 672 + "</D:activelock></D:lockdiscovery>\n" 673 + "</D:prop>\n"; 674 + 675 + s.sendHeader("Lock-Token", "<urn:uuid:dummy-lock-token>"); 676 + s.send(200, "application/xml; charset=\"utf-8\"", xml); 677 + } 678 + 679 + void WebDAVHandler::handleUnlock(WebServer& s) { 680 + LOG_DBG("DAV", "UNLOCK %s (dummy)", s.uri().c_str()); 681 + s.send(204); 682 + } 683 + 684 + // ── Utility functions ──────────────────────────────────────────────────────── 685 + 686 + String WebDAVHandler::getRequestPath(WebServer& s) const { 687 + String uri = s.uri(); 688 + String decoded = WebServer::urlDecode(uri); 689 + 690 + // Normalize using FsHelpers 691 + std::string normalized = FsHelpers::normalisePath(decoded.c_str()); 692 + String result = normalized.c_str(); 693 + 694 + if (result.isEmpty()) return "/"; 695 + if (!result.startsWith("/")) result = "/" + result; 696 + 697 + // Remove trailing slash unless root 698 + if (result.length() > 1 && result.endsWith("/")) { 699 + result = result.substring(0, result.length() - 1); 700 + } 701 + 702 + return result; 703 + } 704 + 705 + String WebDAVHandler::getDestinationPath(WebServer& s) const { 706 + String dest = s.header("Destination"); 707 + if (dest.isEmpty()) return ""; 708 + 709 + // Extract path from full URL: http://host/path -> /path 710 + // Find the third slash (after http://) 711 + int schemeEnd = dest.indexOf("://"); 712 + if (schemeEnd >= 0) { 713 + int pathStart = dest.indexOf('/', schemeEnd + 3); 714 + if (pathStart >= 0) { 715 + dest = dest.substring(pathStart); 716 + } else { 717 + dest = "/"; 718 + } 719 + } 720 + 721 + String decoded = WebServer::urlDecode(dest); 722 + std::string normalized = FsHelpers::normalisePath(decoded.c_str()); 723 + String result = normalized.c_str(); 724 + 725 + if (result.isEmpty()) return "/"; 726 + if (!result.startsWith("/")) result = "/" + result; 727 + 728 + // Remove trailing slash unless root 729 + if (result.length() > 1 && result.endsWith("/")) { 730 + result = result.substring(0, result.length() - 1); 731 + } 732 + 733 + return result; 734 + } 735 + 736 + void WebDAVHandler::urlEncodePath(const String& path, String& out) const { 737 + out = ""; 738 + for (unsigned int i = 0; i < path.length(); i++) { 739 + char c = path.charAt(i); 740 + if (c == '/') { 741 + out += '/'; 742 + } else if (c == ' ') { 743 + out += "%20"; 744 + } else if (c == '%') { 745 + out += "%25"; 746 + } else if (c == '#') { 747 + out += "%23"; 748 + } else if (c == '?') { 749 + out += "%3F"; 750 + } else if (c == '&') { 751 + out += "%26"; 752 + } else if ((uint8_t)c > 127) { 753 + // Encode non-ASCII bytes 754 + char hex[4]; 755 + snprintf(hex, sizeof(hex), "%%%02X", (uint8_t)c); 756 + out += hex; 757 + } else { 758 + out += c; 759 + } 760 + } 761 + } 762 + 763 + bool WebDAVHandler::isProtectedPath(const String& path) const { 764 + // Check every segment of the path, not just the last one. 765 + // This prevents access to e.g. /.hidden/somefile or /System Volume Information/foo 766 + int start = 0; 767 + while (start < (int)path.length()) { 768 + if (path.charAt(start) == '/') { 769 + start++; 770 + continue; 771 + } 772 + int end = path.indexOf('/', start); 773 + if (end == -1) end = path.length(); 774 + 775 + String segment = path.substring(start, end); 776 + 777 + if (segment.startsWith(".")) return true; 778 + 779 + for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) { 780 + if (segment.equals(HIDDEN_ITEMS[i])) return true; 781 + } 782 + 783 + start = end + 1; 784 + } 785 + 786 + return false; 787 + } 788 + 789 + int WebDAVHandler::getDepth(WebServer& s) const { 790 + String depth = s.header("Depth"); 791 + if (depth == "0") return 0; 792 + if (depth == "1") return 1; 793 + // "infinity" or missing → treat as 1 (Class 1 servers don't need to support infinity) 794 + return 1; 795 + } 796 + 797 + bool WebDAVHandler::getOverwrite(WebServer& s) const { 798 + String ow = s.header("Overwrite"); 799 + if (ow == "F" || ow == "f") return false; 800 + return true; // Default is T 801 + } 802 + 803 + void WebDAVHandler::clearEpubCacheIfNeeded(const String& path) const { 804 + if (StringUtils::checkFileExtension(path, ".epub")) { 805 + Epub(path.c_str(), "/.crosspoint").clearCache(); 806 + LOG_DBG("DAV", "Cleared epub cache for: %s", path.c_str()); 807 + } 808 + } 809 + 810 + String WebDAVHandler::getMimeType(const String& path) const { 811 + if (StringUtils::checkFileExtension(path, ".epub")) return "application/epub+zip"; 812 + if (StringUtils::checkFileExtension(path, ".pdf")) return "application/pdf"; 813 + if (StringUtils::checkFileExtension(path, ".txt")) return "text/plain"; 814 + if (StringUtils::checkFileExtension(path, ".html") || StringUtils::checkFileExtension(path, ".htm")) 815 + return "text/html"; 816 + if (StringUtils::checkFileExtension(path, ".css")) return "text/css"; 817 + if (StringUtils::checkFileExtension(path, ".js")) return "application/javascript"; 818 + if (StringUtils::checkFileExtension(path, ".json")) return "application/json"; 819 + if (StringUtils::checkFileExtension(path, ".xml")) return "application/xml"; 820 + if (StringUtils::checkFileExtension(path, ".jpg") || StringUtils::checkFileExtension(path, ".jpeg")) 821 + return "image/jpeg"; 822 + if (StringUtils::checkFileExtension(path, ".png")) return "image/png"; 823 + if (StringUtils::checkFileExtension(path, ".gif")) return "image/gif"; 824 + if (StringUtils::checkFileExtension(path, ".svg")) return "image/svg+xml"; 825 + if (StringUtils::checkFileExtension(path, ".zip")) return "application/zip"; 826 + if (StringUtils::checkFileExtension(path, ".gz")) return "application/gzip"; 827 + return "application/octet-stream"; 828 + }
+44
src/network/WebDAVHandler.h
··· 1 + #pragma once 2 + 3 + #include <HalStorage.h> 4 + #include <WebServer.h> 5 + 6 + class WebDAVHandler : public RequestHandler { 7 + public: 8 + // RequestHandler interface 9 + bool canHandle(WebServer& server, HTTPMethod method, const String& uri) override; 10 + bool canRaw(WebServer& server, const String& uri) override; 11 + void raw(WebServer& server, const String& uri, HTTPRaw& raw) override; 12 + bool handle(WebServer& server, HTTPMethod method, const String& uri) override; 13 + 14 + private: 15 + // PUT streaming state (raw() is called in chunks) 16 + FsFile _putFile; 17 + String _putPath; 18 + bool _putOk = false; 19 + bool _putExisted = false; 20 + 21 + // WebDAV method handlers 22 + void handleOptions(WebServer& s); 23 + void handlePropfind(WebServer& s); 24 + void handleGet(WebServer& s); 25 + void handleHead(WebServer& s); 26 + void handlePut(WebServer& s); 27 + void handleDelete(WebServer& s); 28 + void handleMkcol(WebServer& s); 29 + void handleMove(WebServer& s); 30 + void handleCopy(WebServer& s); 31 + void handleLock(WebServer& s); 32 + void handleUnlock(WebServer& s); 33 + 34 + // Utilities 35 + String getRequestPath(WebServer& s) const; 36 + String getDestinationPath(WebServer& s) const; 37 + void urlEncodePath(const String& path, String& out) const; 38 + bool isProtectedPath(const String& path) const; 39 + int getDepth(WebServer& s) const; 40 + bool getOverwrite(WebServer& s) const; 41 + void clearEpubCacheIfNeeded(const String& path) const; 42 + void sendPropEntry(WebServer& s, const String& href, bool isDir, size_t size, const String& lastModified) const; 43 + String getMimeType(const String& path) const; 44 + };