server.cpp
1 #include "../http.h" 2 #include "../cloudevents.h" 3 #include "../ws_shell.h" 4 #include <storage.h> 5 #include <networking/wifi.h> 6 #include "api/api.h" 7 #include <config.h> 8 9 #include <Arduino.h> 10 #include <WiFi.h> 11 #include <ESPAsyncWebServer.h> 12 #include <LittleFS.h> 13 #include <SD.h> 14 15 namespace { 16 17 AsyncWebServer server(config::http::PORT); 18 19 AsyncCorsMiddleware cors; 20 AsyncLoggingMiddleware logging; 21 AsyncAuthenticationMiddleware auth; 22 AsyncRateLimitMiddleware scan_limit; 23 AsyncRateLimitMiddleware reset_limit; 24 AsyncRateLimitMiddleware ota_limit; 25 AsyncRateLimitMiddleware format_limit; 26 27 bool sd_has_index() { 28 if (!hardware::storage::ensureSD()) return false; 29 return SD.exists("/index.html") || SD.exists("/index.html.gz"); 30 } 31 32 void send_index_from_sd(AsyncWebServerRequest *request) { 33 if (SD.exists("/index.html.gz")) { 34 AsyncWebServerResponse *response = request->beginResponse(SD, "/index.html.gz", "text/html; charset=utf-8"); 35 response->addHeader(asyncsrv::T_Content_Encoding, "gzip"); 36 request->send(response); 37 } else { 38 request->send(SD, "/index.html", "text/html; charset=utf-8"); 39 } 40 } 41 42 void captive_portal_redirect(AsyncWebServerRequest *request) { 43 String location = "http://" + WiFi.softAPIP().toString() + "/"; 44 request->redirect(location); 45 } 46 47 void send_portal_page(AsyncWebServerRequest *request) { 48 if (sd_has_index()) { 49 send_index_from_sd(request); 50 return; 51 } 52 request->send(200, "text/html; charset=utf-8", 53 "<!DOCTYPE html><html><head><title>Ceratina</title></head><body>" 54 "<h1>Ceratina</h1><p>Portal UI unavailable (index.html missing on SD).</p>" 55 "</body></html>"); 56 } 57 58 class CaptivePortalRedirectHandler : public AsyncWebHandler { 59 public: 60 bool canHandle(AsyncWebServerRequest *request) const override { 61 if (!request || !request->hasHeader("Host")) return true; 62 const AsyncWebHeader *host = request->getHeader("Host"); 63 if (!host) return true; 64 String h = host->value(); 65 h.toLowerCase(); 66 String ap_ip = WiFi.softAPIP().toString(); 67 if (h == ap_ip || h.startsWith(ap_ip + ":")) return false; 68 if (h.startsWith("ceratina")) return false; 69 return true; 70 } 71 72 void handleRequest(AsyncWebServerRequest *request) override { 73 captive_portal_redirect(request); 74 } 75 }; 76 77 } 78 79 static AsyncEventSource http_events("/events"); 80 81 void services::http::service() { 82 } 83 84 void services::http::emitEvent(const char *data, const char *event, unsigned long id) { 85 http_events.send(data, event, id); 86 } 87 88 size_t services::http::sseClientCount() { 89 return http_events.count(); 90 } 91 92 size_t services::http::sseAvgPacketsWaiting() { 93 return http_events.avgPacketsWaiting(); 94 } 95 96 void services::http::initialize() { 97 DefaultHeaders::Instance().addHeader("X-Firmware", "ceratina"); 98 DefaultHeaders::Instance().addHeader("X-Platform", config::PLATFORM); 99 100 cors.setOrigin("*"); 101 cors.setMethods("GET, POST, PUT, PATCH, DELETE, OPTIONS"); 102 cors.setHeaders("Content-Type, Authorization"); 103 server.addMiddleware(&cors); 104 105 #if CERATINA_HTTP_AUTH_ENABLED 106 auth.setUsername(config::http::AUTH_USER); 107 auth.setPassword(config::http::AUTH_PASSWORD); 108 auth.setRealm(config::http::AUTH_REALM); 109 auth.setAuthType(AsyncAuthType::AUTH_DIGEST); 110 auth.generateHash(); 111 server.addMiddleware(&auth); 112 #endif 113 114 scan_limit.setMaxRequests(3); 115 scan_limit.setWindowSize(30); 116 reset_limit.setMaxRequests(1); 117 reset_limit.setWindowSize(10); 118 ota_limit.setMaxRequests(1); 119 ota_limit.setWindowSize(60); 120 format_limit.setMaxRequests(1); 121 format_limit.setWindowSize(30); 122 123 logging.setOutput(Serial); 124 logging.setEnabled(true); 125 server.addMiddleware(&logging); 126 127 WiFi.setScanTimeout(config::wifi::CONNECT_TIMEOUT_MS); 128 129 http_events.onConnect([](AsyncEventSourceClient *client) { 130 client->send("connected", "status", millis(), 5000); 131 }); 132 #if CERATINA_HTTP_AUTH_ENABLED 133 http_events.authorizeConnect([](AsyncWebServerRequest *request) { 134 return request->authenticate(config::http::AUTH_USER, config::http::AUTH_PASSWORD); 135 }); 136 #endif 137 server.addHandler(&http_events); 138 139 services::http::api::system::registerRoutes(server, reset_limit, ota_limit); 140 services::http::api::filesystem::registerRoutes(server, format_limit); 141 services::http::api::networking::registerRoutes(server, scan_limit); 142 services::http::api::sensors::registerRoutes(server); 143 services::http::api::database::registerRoutes(server); 144 services::http::api::email::registerRoutes(server); 145 146 services::cloudevents::registerRoutes(&server); 147 services::ws_shell::registerRoutes(&server); 148 149 hardware::storage::ensureSD(); 150 server.serveStatic("/", SD, "/") 151 .setDefaultFile("index.html") 152 .setCacheControl("public, max-age=86400") 153 .setLastModified() 154 .setTryGzipFirst(true); 155 156 server.on("/portal", HTTP_GET, send_portal_page); 157 server.on(AsyncURIMatcher::exact("/generate_204"), HTTP_GET, captive_portal_redirect); 158 server.on(AsyncURIMatcher::exact("/gen_204"), HTTP_GET, captive_portal_redirect); 159 server.on(AsyncURIMatcher::exact("/fwlink"), HTTP_GET, captive_portal_redirect); 160 server.on(AsyncURIMatcher::exact("/redirect"), HTTP_GET, captive_portal_redirect); 161 server.on(AsyncURIMatcher::exact("/hotspot-detect.html"), HTTP_GET, captive_portal_redirect); 162 server.on(AsyncURIMatcher::exact("/canonical.html"), HTTP_GET, captive_portal_redirect); 163 server.on(AsyncURIMatcher::exact("/mobile/status.php"), HTTP_GET, captive_portal_redirect); 164 server.on(AsyncURIMatcher::exact("/connecttest.txt"), HTTP_GET, captive_portal_redirect); 165 server.on(AsyncURIMatcher::exact("/ncsi.txt"), HTTP_GET, captive_portal_redirect); 166 server.on(AsyncURIMatcher::exact("/success.txt"), HTTP_GET, captive_portal_redirect); 167 168 server.addHandler(new CaptivePortalRedirectHandler()).setFilter(ON_AP_FILTER); 169 170 server.onNotFound([](AsyncWebServerRequest *request) { 171 if (sd_has_index()) { 172 send_index_from_sd(request); 173 return; 174 } 175 176 request->send(404, asyncsrv::T_application_json, "{\"error\":\"not found\"}"); 177 }); 178 179 server.begin(); 180 Serial.printf("[http] listening on port %d\n", config::http::PORT); 181 } 182 183 //------------------------------------------ 184 // Tests 185 //------------------------------------------ 186 #ifdef PIO_UNIT_TESTING 187 188 #include <testing/utils.h> 189 190 // TODO: HTTP server tests require the server running and a WiFiClient 191 // on the same network. The e2e tests in http.cpp already cover endpoint 192 // responses. These stubs test server-level behavior. 193 // 194 // static void http_server_test_serves_index_from_sd(void) { 195 // TEST_MESSAGE("user verifies GET / serves index.html from SD card"); 196 // // Requires: SD mounted with /index.html, server initialized 197 // // GET / should return 200 with HTML content 198 // } 199 // 200 // static void http_server_test_captive_portal_redirect(void) { 201 // TEST_MESSAGE("user verifies captive portal redirects unknown hosts"); 202 // // Requires: AP mode active, client sending Host header != AP IP 203 // // GET / with Host: captive.apple.com should 302 → http://192.168.4.1/ 204 // } 205 // 206 // static void http_server_test_404_returns_json(void) { 207 // TEST_MESSAGE("user verifies unknown routes return JSON 404"); 208 // // GET /nonexistent → 404 {"error":"not found"} 209 // } 210 // 211 // static void http_server_test_cors_headers_present(void) { 212 // TEST_MESSAGE("user verifies CORS headers on OPTIONS preflight"); 213 // // OPTIONS /api/system/device/status 214 // // Should have Access-Control-Allow-Origin: * 215 // // Should have Access-Control-Allow-Methods: GET, POST, ... 216 // } 217 // 218 // static void http_server_test_rate_limit_enforced(void) { 219 // TEST_MESSAGE("user verifies rate limit rejects excess requests"); 220 // // POST /api/system/reset 3 times in 10s → third should get 429 221 // } 222 // 223 // static void http_server_test_sse_connect(void) { 224 // TEST_MESSAGE("user connects to /events SSE stream"); 225 // // GET /events → 200, receives "connected" event 226 // } 227 // 228 // static void http_server_test_x_firmware_header(void) { 229 // TEST_MESSAGE("user verifies X-Firmware header on all responses"); 230 // // Any GET → response should have X-Firmware: ceratina 231 // } 232 233 #endif