408 lines
11 KiB
C++
408 lines
11 KiB
C++
#include "TBeamWeb.h"
|
|
|
|
#include <SD.h>
|
|
#include <stdarg.h>
|
|
|
|
namespace tbeam {
|
|
|
|
TBeamWeb* TBeamWeb::active_ = nullptr;
|
|
|
|
TBeamWeb::TBeamWeb(Print& diagnostic) : diagnostic_(diagnostic), server_(80) {}
|
|
|
|
bool TBeamWeb::begin(TBeamStorage& storage, const WebConfig& config) {
|
|
storage_ = &storage;
|
|
config_ = config;
|
|
clearError();
|
|
|
|
snprintf(ssid_, sizeof(ssid_), "%s-%s", config_.ssidPrefix ? config_.ssidPrefix : "TBEAM", config_.boardId ? config_.boardId : "NODE");
|
|
|
|
WiFi.mode(WIFI_AP);
|
|
WiFi.setSleep(false);
|
|
|
|
ip_ = IPAddress(192, 168, config_.ipOctet, 1);
|
|
const IPAddress gateway(192, 168, config_.ipOctet, 1);
|
|
const IPAddress netmask(255, 255, 255, 0);
|
|
if (!WiFi.softAPConfig(ip_, gateway, netmask)) {
|
|
setError("WiFi softAPConfig failed");
|
|
return false;
|
|
}
|
|
|
|
bool apOk = false;
|
|
if (config_.password && strlen(config_.password) >= 8) {
|
|
apOk = WiFi.softAP(ssid_, config_.password);
|
|
} else {
|
|
apOk = WiFi.softAP(ssid_);
|
|
}
|
|
if (!apOk) {
|
|
setError("WiFi softAP failed");
|
|
return false;
|
|
}
|
|
ip_ = WiFi.softAPIP();
|
|
|
|
active_ = this;
|
|
server_.on("/", HTTP_GET, handleRootThunk);
|
|
server_.on("/status", HTTP_GET, handleStatusThunk);
|
|
server_.on("/files", HTTP_GET, handleFilesThunk);
|
|
server_.on("/download", HTTP_GET, handleDownloadThunk);
|
|
server_.on("/delete", HTTP_POST, handleDeleteThunk);
|
|
server_.onNotFound(handleNotFoundThunk);
|
|
server_.begin(config_.port);
|
|
|
|
ready_ = true;
|
|
logf("web: AP started ssid=%s ip=%s port=%u", ssid_, ip_.toString().c_str(), (unsigned)config_.port);
|
|
return true;
|
|
}
|
|
|
|
void TBeamWeb::update() {
|
|
if (ready_) {
|
|
server_.handleClient();
|
|
}
|
|
}
|
|
|
|
void TBeamWeb::stop() {
|
|
if (ready_) {
|
|
server_.stop();
|
|
WiFi.softAPdisconnect(true);
|
|
}
|
|
ready_ = false;
|
|
}
|
|
|
|
uint8_t TBeamWeb::stationCount() const {
|
|
return (uint8_t)WiFi.softAPgetStationNum();
|
|
}
|
|
|
|
void TBeamWeb::handleRootThunk() {
|
|
if (active_) active_->handleRoot();
|
|
}
|
|
|
|
void TBeamWeb::handleStatusThunk() {
|
|
if (active_) active_->handleStatus();
|
|
}
|
|
|
|
void TBeamWeb::handleFilesThunk() {
|
|
if (active_) active_->handleFiles();
|
|
}
|
|
|
|
void TBeamWeb::handleDownloadThunk() {
|
|
if (active_) active_->handleDownload();
|
|
}
|
|
|
|
void TBeamWeb::handleDeleteThunk() {
|
|
if (active_) active_->handleDelete();
|
|
}
|
|
|
|
void TBeamWeb::handleNotFoundThunk() {
|
|
if (active_) active_->handleNotFound();
|
|
}
|
|
|
|
void TBeamWeb::handleRoot() {
|
|
bool countTruncated = false;
|
|
const bool sdReady = storage_ && storage_->ready();
|
|
const size_t logsCount = sdReady ? countDirectoryEntries("/logs", 250, &countTruncated) : 0;
|
|
|
|
String body;
|
|
body.reserve(2048);
|
|
body += F("<!doctype html><html><head><meta charset='utf-8'>");
|
|
body += F("<meta name='viewport' content='width=device-width,initial-scale=1'>");
|
|
body += F("<title>T-Beam Status</title></head><body>");
|
|
body += F("<h1>T-Beam Status</h1>");
|
|
body += F("<p>SSID: ");
|
|
body += htmlEscape(ssid_);
|
|
body += F("</p><p>IP: ");
|
|
body += htmlEscape(ip_.toString());
|
|
body += F("</p><p>SD: ");
|
|
body += sdReady ? F("mounted") : F("not mounted");
|
|
body += F("</p><p>Log entries: ");
|
|
if (sdReady) {
|
|
body += String((unsigned long)logsCount);
|
|
if (countTruncated) {
|
|
body += F("+");
|
|
}
|
|
} else {
|
|
body += F("unavailable");
|
|
}
|
|
body += F("</p><p>Stations: ");
|
|
body += String(stationCount());
|
|
body += F("</p><p><a href='/files?path=/logs'>Files</a> ");
|
|
body += F("<a href='/status'>Status</a></p>");
|
|
body += F("</body></html>");
|
|
server_.send(200, "text/html", body);
|
|
}
|
|
|
|
void TBeamWeb::handleStatus() {
|
|
String body;
|
|
body.reserve(512);
|
|
body += F("{\"ready\":");
|
|
body += ready_ ? F("true") : F("false");
|
|
body += F(",\"ssid\":\"");
|
|
body += htmlEscape(ssid_);
|
|
body += F("\",\"ip\":\"");
|
|
body += ip_.toString();
|
|
body += F("\",\"stations\":");
|
|
body += String(stationCount());
|
|
body += F(",\"sd_ready\":");
|
|
body += (storage_ && storage_->ready()) ? F("true") : F("false");
|
|
body += F("}");
|
|
server_.send(200, "application/json", body);
|
|
}
|
|
|
|
void TBeamWeb::handleFiles() {
|
|
if (!storage_ || !storage_->ready()) {
|
|
server_.send(503, "text/plain", "SD not mounted\n");
|
|
return;
|
|
}
|
|
|
|
char path[128];
|
|
const String requested = server_.hasArg("path") ? server_.arg("path") : String("/logs");
|
|
if (!normalizePath(requested, path, sizeof(path))) {
|
|
server_.send(400, "text/plain", "invalid path\n");
|
|
return;
|
|
}
|
|
|
|
String body;
|
|
body.reserve(8192);
|
|
body += F("<!doctype html><html><head><meta charset='utf-8'>");
|
|
body += F("<meta name='viewport' content='width=device-width,initial-scale=1'>");
|
|
body += F("<title>SD Files</title></head><body>");
|
|
body += F("<h1>SD Files</h1><p>Path: ");
|
|
body += htmlEscape(path);
|
|
body += F("</p><p><a href='/'>Home</a> <a href='/files?path=/'>Root</a> <a href='/files?path=/logs'>Logs</a></p><ul>");
|
|
listDirectoryHtml(body, path, 0);
|
|
body += F("</ul></body></html>");
|
|
server_.send(200, "text/html", body);
|
|
}
|
|
|
|
void TBeamWeb::handleDownload() {
|
|
if (!storage_ || !storage_->ready()) {
|
|
server_.send(503, "text/plain", "SD not mounted\n");
|
|
return;
|
|
}
|
|
if (!server_.hasArg("path")) {
|
|
server_.send(400, "text/plain", "missing path\n");
|
|
return;
|
|
}
|
|
|
|
char path[128];
|
|
if (!normalizePath(server_.arg("path"), path, sizeof(path))) {
|
|
server_.send(400, "text/plain", "invalid path\n");
|
|
return;
|
|
}
|
|
|
|
File file = SD.open(path, FILE_READ);
|
|
if (!file || file.isDirectory()) {
|
|
file.close();
|
|
server_.send(404, "text/plain", "file not found\n");
|
|
return;
|
|
}
|
|
|
|
String filename(path);
|
|
const int slash = filename.lastIndexOf('/');
|
|
if (slash >= 0) {
|
|
filename.remove(0, slash + 1);
|
|
}
|
|
server_.sendHeader("Content-Disposition", String("attachment; filename=\"") + filename + "\"");
|
|
server_.streamFile(file, contentTypeFor(path));
|
|
file.close();
|
|
}
|
|
|
|
void TBeamWeb::handleDelete() {
|
|
if (!config_.enableDelete) {
|
|
server_.send(403, "text/plain", "delete disabled\n");
|
|
return;
|
|
}
|
|
if (!storage_ || !storage_->ready()) {
|
|
server_.send(503, "text/plain", "SD not mounted\n");
|
|
return;
|
|
}
|
|
if (!server_.hasArg("path")) {
|
|
server_.send(400, "text/plain", "missing path\n");
|
|
return;
|
|
}
|
|
|
|
char path[128];
|
|
if (!normalizePath(server_.arg("path"), path, sizeof(path))) {
|
|
server_.send(400, "text/plain", "invalid path\n");
|
|
return;
|
|
}
|
|
|
|
if (!storage_->removeFile(path)) {
|
|
server_.send(500, "text/plain", String("delete failed: ") + storage_->lastError() + "\n");
|
|
return;
|
|
}
|
|
server_.sendHeader("Location", "/files?path=/logs");
|
|
server_.send(303, "text/plain", "deleted\n");
|
|
}
|
|
|
|
void TBeamWeb::handleNotFound() {
|
|
server_.send(404, "text/plain", "not found\n");
|
|
}
|
|
|
|
void TBeamWeb::listDirectoryHtml(String& body, const char* path, uint8_t depth) {
|
|
File dir = SD.open(path, FILE_READ);
|
|
if (!dir) {
|
|
body += F("<li>open failed</li>");
|
|
return;
|
|
}
|
|
|
|
if (!dir.isDirectory()) {
|
|
body += F("<li>");
|
|
body += htmlEscape(path);
|
|
body += F(" ");
|
|
body += String((unsigned long)dir.size());
|
|
body += F(" bytes <a href='/download?path=");
|
|
body += urlEncode(path);
|
|
body += F("'>download</a></li>");
|
|
dir.close();
|
|
return;
|
|
}
|
|
|
|
File entry = dir.openNextFile();
|
|
while (entry) {
|
|
const String name = entry.name();
|
|
const int slash = name.lastIndexOf('/');
|
|
const String displayName = slash >= 0 ? name.substring(slash + 1) : (name.length() ? name : String("(unnamed)"));
|
|
String childPath;
|
|
if (name.startsWith("/")) {
|
|
childPath = name;
|
|
} else if (strcmp(path, "/") == 0) {
|
|
childPath = String("/") + name;
|
|
} else {
|
|
childPath = String(path) + "/" + name;
|
|
}
|
|
body += F("<li>");
|
|
if (entry.isDirectory()) {
|
|
body += F("<a href='/files?path=");
|
|
body += urlEncode(childPath);
|
|
body += F("'>");
|
|
body += htmlEscape(displayName);
|
|
body += F("/</a>");
|
|
if (depth > 0) {
|
|
body += F("<ul>");
|
|
listDirectoryHtml(body, childPath.c_str(), depth - 1);
|
|
body += F("</ul>");
|
|
}
|
|
} else {
|
|
body += htmlEscape(displayName);
|
|
body += F(" ");
|
|
body += String((unsigned long)entry.size());
|
|
body += F(" bytes <a href='/download?path=");
|
|
body += urlEncode(childPath);
|
|
body += F("'>download</a>");
|
|
if (config_.enableDelete) {
|
|
body += F(" <form method='post' action='/delete' style='display:inline'>");
|
|
body += F("<input type='hidden' name='path' value='");
|
|
body += htmlEscape(childPath);
|
|
body += F("'><button type='submit'>delete</button></form>");
|
|
}
|
|
}
|
|
body += F("</li>");
|
|
entry.close();
|
|
entry = dir.openNextFile();
|
|
}
|
|
dir.close();
|
|
}
|
|
|
|
size_t TBeamWeb::countDirectoryEntries(const char* path, size_t maxEntries, bool* truncated) {
|
|
if (truncated) {
|
|
*truncated = false;
|
|
}
|
|
if (maxEntries == 0) {
|
|
return 0;
|
|
}
|
|
File dir = SD.open(path, FILE_READ);
|
|
if (!dir) {
|
|
return 0;
|
|
}
|
|
if (!dir.isDirectory()) {
|
|
dir.close();
|
|
return 1;
|
|
}
|
|
|
|
size_t count = 0;
|
|
File entry = dir.openNextFile();
|
|
while (entry) {
|
|
entry.close();
|
|
++count;
|
|
if (count >= maxEntries) {
|
|
File extra = dir.openNextFile();
|
|
if (extra) {
|
|
if (truncated) {
|
|
*truncated = true;
|
|
}
|
|
extra.close();
|
|
}
|
|
break;
|
|
}
|
|
entry = dir.openNextFile();
|
|
}
|
|
dir.close();
|
|
return count;
|
|
}
|
|
|
|
bool TBeamWeb::normalizePath(const String& input, char* out, size_t outSize) const {
|
|
if (!out || outSize < 2 || input.length() == 0 || input.indexOf("..") >= 0) {
|
|
return false;
|
|
}
|
|
const int n = input[0] == '/' ? snprintf(out, outSize, "%s", input.c_str()) : snprintf(out, outSize, "/%s", input.c_str());
|
|
return n > 0 && (size_t)n < outSize;
|
|
}
|
|
|
|
String TBeamWeb::htmlEscape(const String& in) const {
|
|
String out;
|
|
out.reserve(in.length() + 8);
|
|
for (size_t i = 0; i < in.length(); ++i) {
|
|
const char c = in[i];
|
|
if (c == '&') out += F("&");
|
|
else if (c == '<') out += F("<");
|
|
else if (c == '>') out += F(">");
|
|
else if (c == '"') out += F(""");
|
|
else if (c == '\'') out += F("'");
|
|
else out += c;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
String TBeamWeb::urlEncode(const String& in) const {
|
|
String out;
|
|
char hex[4];
|
|
for (size_t i = 0; i < in.length(); ++i) {
|
|
const unsigned char c = (unsigned char)in[i];
|
|
if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '/' || c == '~') {
|
|
out += (char)c;
|
|
} else {
|
|
snprintf(hex, sizeof(hex), "%%%02X", c);
|
|
out += hex;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
String TBeamWeb::contentTypeFor(const String& path) const {
|
|
if (path.endsWith(".html")) return "text/html";
|
|
if (path.endsWith(".csv")) return "text/csv";
|
|
if (path.endsWith(".json")) return "application/json";
|
|
if (path.endsWith(".txt") || path.endsWith(".log")) return "text/plain";
|
|
return "application/octet-stream";
|
|
}
|
|
|
|
void TBeamWeb::setError(const char* message) {
|
|
strlcpy(lastError_, message ? message : "", sizeof(lastError_));
|
|
}
|
|
|
|
void TBeamWeb::clearError() {
|
|
lastError_[0] = '\0';
|
|
}
|
|
|
|
void TBeamWeb::logf(const char* fmt, ...) {
|
|
if (!config_.enableSerialLog) {
|
|
return;
|
|
}
|
|
char msg[196];
|
|
va_list args;
|
|
va_start(args, fmt);
|
|
vsnprintf(msg, sizeof(msg), fmt, args);
|
|
va_end(args);
|
|
diagnostic_.printf("[%10lu][web] %s\r\n", (unsigned long)millis(), msg);
|
|
}
|
|
|
|
} // namespace tbeam
|