#include #include #include #include // SDK #include "esp_http_server.h" #include "esp_log.h" #include "esp_vfs.h" #ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES #include "esp_littlefs.h" #endif // Project #include "api/ota/bundle.cpp" #include "api/ota/firmware.cpp" #include "api/ota/frontend.cpp" #include "api/ota/status.cpp" #include "api/system/info.cpp" #include "api/system/reboot.cpp" #include "api/tasks/unity.cpp" #include "api/users/unity.cpp" internal const char *TAG = "HTTP_SERVER"; constexpr uint8 kGZ_Extension_Length = sizeof(".gz") - 1; #define FILE_PATH_MAX (ESP_VFS_PATH_MAX + 128) #define SCRATCH_BUFSIZE 4096 #define MAX_SCRATCH_BUFFERS 10 typedef struct { char *buffers[MAX_SCRATCH_BUFFERS]; bool in_use[MAX_SCRATCH_BUFFERS]; } scratch_pool_t; static scratch_pool_t global_scratch_pool = {}; char *get_scratch_buffer() { for (int i = 0; i < MAX_SCRATCH_BUFFERS; i++) { if (!global_scratch_pool.in_use[i]) { if (global_scratch_pool.buffers[i] == NULL) { global_scratch_pool.buffers[i] = (char *)malloc(SCRATCH_BUFSIZE); if (global_scratch_pool.buffers[i] == NULL) { ESP_LOGE(TAG, "Failed to allocate scratch buffer from heap!"); return NULL; } } global_scratch_pool.in_use[i] = true; return global_scratch_pool.buffers[i]; } } ESP_LOGE(TAG, "All scratch buffers in use! Increase MAX_SCRATCH_BUFFERS"); return NULL; } void free_scratch_buffer(char *buffer) { if (buffer == NULL) return; for (int i = 0; i < MAX_SCRATCH_BUFFERS; i++) { if (global_scratch_pool.buffers[i] == buffer) { global_scratch_pool.in_use[i] = false; return; } } ESP_LOGE(TAG, "Attempted to free unknown scratch buffer!"); } #ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES // Set HTTP response content type according to file extension internal esp_err_t set_content_type_from_file(httpd_req_t *req, const char *filepath) { const char *type = "text/plain"; if (strstr(filepath, ".html")) { type = "text/html"; } else if (strstr(filepath, ".js")) { type = "application/javascript"; } else if (strstr(filepath, ".css")) { type = "text/css"; } else if (strstr(filepath, ".png")) { type = "image/png"; } else if (strstr(filepath, ".ico")) { type = "image/x-icon"; } else if (strstr(filepath, ".svg")) { type = "text/xml"; } return httpd_resp_set_type(req, type); } // Handler to serve static files from LittleFS internal esp_err_t static_file_handler(httpd_req_t *req) { char filepath[FILE_PATH_MAX]; // Construct real file path strlcpy(filepath, "/www", sizeof(filepath)); if (req->uri[strlen(req->uri) - 1] == '/') { strlcat(filepath, "/index.html", sizeof(filepath)); } else { strlcat(filepath, req->uri, sizeof(filepath)); } // Default to index.html if file doesn't exist (SPA routing support) struct stat file_stat; if (stat(filepath, &file_stat) == -1) { // Try gzipped first, then fallback to index.html char filepath_gz[FILE_PATH_MAX + kGZ_Extension_Length]; snprintf(filepath_gz, sizeof(filepath_gz), "%s.gz", filepath); if (stat(filepath_gz, &file_stat) == 0) { strlcpy(filepath, filepath_gz, sizeof(filepath)); httpd_resp_set_hdr(req, "Content-Encoding", "gzip"); } else { ESP_LOGW(TAG, "File not found: %s, falling back to index.html", filepath); snprintf(filepath, sizeof(filepath), "%s/index.html", "/www"); if (stat(filepath, &file_stat) == -1) { // If index.html doesn't exist, try index.html.gz snprintf(filepath_gz, sizeof(filepath_gz), "%s/index.html.gz", "/www"); if (stat(filepath_gz, &file_stat) == 0) { strlcpy(filepath, filepath_gz, sizeof(filepath)); httpd_resp_set_hdr(req, "Content-Encoding", "gzip"); } else { ESP_LOGE(TAG, "index.html not found too."); httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "File not found"); return ESP_FAIL; } } } } else { // Since ESP32 handles .gz transparently if we tell it to via headers // If we requested explicitly a .gz file, set the header if (strstr(filepath, ".gz")) { httpd_resp_set_hdr(req, "Content-Encoding", "gzip"); } } int fd = open(filepath, O_RDONLY, 0); if (fd == -1) { ESP_LOGE(TAG, "Failed to open file: %s", filepath); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to read existing file"); return ESP_FAIL; } set_content_type_from_file(req, filepath); char *chunk = get_scratch_buffer(); if (chunk == NULL) { close(fd); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Server busy"); return ESP_FAIL; } ssize_t read_bytes; do { read_bytes = read(fd, chunk, SCRATCH_BUFSIZE); if (read_bytes == -1) { ESP_LOGE(TAG, "Failed to read file: %s", filepath); } else if (read_bytes > 0) { if (httpd_resp_send_chunk(req, chunk, read_bytes) != ESP_OK) { close(fd); ESP_LOGE(TAG, "File sending failed!"); free_scratch_buffer(chunk); httpd_resp_sendstr_chunk(req, NULL); // Abort sending return ESP_FAIL; } } } while (read_bytes > 0); close(fd); free_scratch_buffer(chunk); httpd_resp_send_chunk(req, NULL, 0); // End response return ESP_OK; } #endif // Handler for CORS Preflight OPTIONS requests internal esp_err_t cors_options_handler(httpd_req_t *req) { httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); httpd_resp_set_hdr(req, "Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); httpd_resp_set_hdr(req, "Access-Control-Allow-Headers", "Content-Type"); httpd_resp_set_status(req, "204 No Content"); httpd_resp_send(req, NULL, 0); return ESP_OK; } internal httpd_handle_t start_webserver(void) { #ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES esp_vfs_littlefs_conf_t conf = {}; conf.base_path = "/www"; conf.partition_label = g_Active_WWW_Partition == 0 ? "www_0" : "www_1"; ESP_LOGI(TAG, "Mounting LittleFS partition: %s", conf.partition_label); conf.format_if_mount_failed = false; conf.dont_mount = false; esp_err_t ret = esp_vfs_littlefs_register(&conf); if (ret != ESP_OK) { if (ret == ESP_FAIL) { ESP_LOGE(TAG, "Failed to mount or format filesystem"); } else if (ret == ESP_ERR_NOT_FOUND) { ESP_LOGE(TAG, "Failed to find LittleFS partition"); } else { ESP_LOGE(TAG, "Failed to initialize LittleFS (%s)", esp_err_to_name(ret)); } } else { ESP_LOGI(TAG, "LittleFS mounted on /www"); } #endif httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.uri_match_fn = httpd_uri_match_wildcard; config.max_uri_handlers = 20; config.max_open_sockets = 24; config.lru_purge_enable = true; httpd_handle_t server = NULL; ESP_LOGI(TAG, "Starting HTTP Server on port: '%d'", config.server_port); if (httpd_start(&server, &config) == ESP_OK) { // Register CORS OPTIONS handler for API routes httpd_uri_t cors_options_uri = {.uri = "/api/*", .method = HTTP_OPTIONS, .handler = cors_options_handler, .user_ctx = NULL}; httpd_register_uri_handler(server, &cors_options_uri); // Register system API routes httpd_register_uri_handler(server, &api_system_info_uri); httpd_register_uri_handler(server, &api_system_reboot_uri); httpd_register_uri_handler(server, &api_ota_status_uri); httpd_register_uri_handler(server, &api_ota_frontend_uri); httpd_register_uri_handler(server, &api_ota_firmware_uri); httpd_register_uri_handler(server, &api_ota_bundle_uri); // Register todo list API routes httpd_register_uri_handler(server, &api_users_get_uri); httpd_register_uri_handler(server, &api_users_post_uri); httpd_register_uri_handler(server, &api_users_update_uri); httpd_register_uri_handler(server, &api_users_delete_uri); httpd_register_uri_handler(server, &api_tasks_upcoming_uri); httpd_register_uri_handler(server, &api_tasks_get_uri); httpd_register_uri_handler(server, &api_tasks_post_uri); httpd_register_uri_handler(server, &api_tasks_update_uri); httpd_register_uri_handler(server, &api_tasks_delete_uri); // Populate dummy data for development (debug builds only) #ifndef NDEBUG seed_users(); seed_tasks(); #endif #ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES // Register static file handler last as a catch-all wildcard if deployed httpd_uri_t static_get_uri = {.uri = "/*", .method = HTTP_GET, .handler = static_file_handler, .user_ctx = NULL}; httpd_register_uri_handler(server, &static_get_uri); #endif return server; } ESP_LOGE(TAG, "Error starting server!"); return NULL; } internal void stop_webserver(httpd_handle_t server, uint8_t partition_index) { if (server) { httpd_stop(server); #ifdef CONFIG_CALENDINK_DEPLOY_WEB_PAGES esp_vfs_littlefs_unregister(partition_index == 0 ? "www_0" : "www_1"); #endif } }