/ firmware / src / services / http / server.cpp
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