/* * sdcard_http.c * * Copyright (c) 2025 Umber Networks & Robert McMahon * SPDX-License-Identifier: BSD-3-Clause * * Serves files from the SD card via HTTP GET /sdcard/. * Use: wget http://:8080/sdcard/myfile.txt * * Telemetry download stats are persisted to telemetry-status on the SD card * and survive reboots. Format: attempts=N, downloads=M, and per-download history * with timestamp and bytes. */ #include "sdcard_http.h" #include "sd_card.h" #include "esp_http_server.h" #include "esp_log.h" #include "sdkconfig.h" #include #include #include #include #include #include static const char *TAG = "sdcard_http"; static httpd_handle_t s_server = NULL; static uint32_t s_telemetry_attempts = 0; static uint32_t s_telemetry_downloads = 0; #define SDCARD_HTTP_CHUNK 2048 #define TELEMETRY_FILE "fiwi-telemetry" #define TELEMETRY_STATUS_FILE "telemetry-status" #define SDCARD_URI_PREFIX "/sdcard" #define STATUS_MAX_SIZE 4096 #define MAX_HISTORY_LINES 50 #if !defined(CONFIG_TELEMETRY_AUTO_DELETE_AFTER_DOWNLOAD) #define CONFIG_TELEMETRY_AUTO_DELETE_AFTER_DOWNLOAD 0 #endif /* Reject path if it contains ".." to avoid traversal */ static bool path_is_safe(const char *path) { if (!path || path[0] == '\0') return false; const char *p = path; while (*p) { if (p[0] == '.' && p[1] == '.') return false; p++; } return true; } /* Load telemetry status from SD card. Format: * attempts=N * downloads=M * --- * timestamp bytes * ... */ static void load_telemetry_status(void) { if (!sd_card_is_ready()) return; if (!sd_card_file_exists(TELEMETRY_STATUS_FILE)) return; static char buf[STATUS_MAX_SIZE]; size_t n = 0; if (sd_card_read_file(TELEMETRY_STATUS_FILE, buf, sizeof(buf) - 1, &n) != ESP_OK || n == 0) return; buf[n] = '\0'; char *p = buf; while (*p) { if (strncmp(p, "attempts=", 9) == 0) { s_telemetry_attempts = (uint32_t)strtoul(p + 9, NULL, 10); } else if (strncmp(p, "downloads=", 10) == 0) { s_telemetry_downloads = (uint32_t)strtoul(p + 10, NULL, 10); } else if (strncmp(p, "---", 3) == 0) { break; /* rest is history, ignore for counts */ } p = strchr(p, '\n'); if (!p) break; p++; } } /* Save telemetry status: attempts, downloads, and history (timestamp bytes per line). * Appends new entry and trims history to MAX_HISTORY_LINES. */ static void save_telemetry_status(size_t bytes_sent) { if (!sd_card_is_ready()) return; time_t ts = time(NULL); if (ts < 0) ts = 0; /* Read existing file to get history */ static char buf[STATUS_MAX_SIZE]; char *history_start = NULL; size_t history_count = 0; if (sd_card_file_exists(TELEMETRY_STATUS_FILE)) { size_t n = 0; if (sd_card_read_file(TELEMETRY_STATUS_FILE, buf, sizeof(buf) - 1, &n) == ESP_OK && n > 0) { buf[n] = '\0'; history_start = strstr(buf, "---\n"); if (history_start) { history_start += 4; char *line = history_start; while (*line && history_count < MAX_HISTORY_LINES) { if (line[0] && line[0] != '\n') history_count++; line = strchr(line, '\n'); if (!line) break; line++; } } } } /* Build new content: header + trimmed history + new entry */ static char out[STATUS_MAX_SIZE]; int len = snprintf(out, sizeof(out), "attempts=%" PRIu32 "\ndownloads=%" PRIu32 "\n---\n", s_telemetry_attempts, s_telemetry_downloads); if (len < 0 || len >= (int)sizeof(out)) return; /* Append existing history (skip oldest if we're at max) */ if (history_start && history_count > 0) { char *line = history_start; size_t skip = (history_count >= MAX_HISTORY_LINES) ? 1 : 0; size_t kept = 0; while (*line && len < (int)sizeof(out) - 64) { if (line[0] && line[0] != '\n') { if (skip > 0) { skip--; } else { char *end = strchr(line, '\n'); size_t line_len = end ? (size_t)(end - line) + 1 : strlen(line); if (len + line_len >= sizeof(out)) break; memcpy(out + len, line, line_len); len += (int)line_len; kept++; if (kept >= MAX_HISTORY_LINES - 1) break; } } line = strchr(line, '\n'); if (!line) break; line++; } } /* Append new entry */ int n = snprintf(out + len, sizeof(out) - (size_t)len, "%ld %zu\n", (long)ts, bytes_sent); if (n > 0) len += n; sd_card_write_file(TELEMETRY_STATUS_FILE, out, (size_t)len, false); } static esp_err_t sdcard_file_handler(httpd_req_t *req) { if (!sd_card_is_ready()) { httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "SD card not mounted"); return ESP_OK; } /* URI is /sdcard/foo/bar -> path = foo/bar */ const char *uri = req->uri; if (strncmp(uri, SDCARD_URI_PREFIX, strlen(SDCARD_URI_PREFIX)) != 0) { return ESP_FAIL; } const char *path = uri + strlen(SDCARD_URI_PREFIX); if (path[0] == '/') path++; if (path[0] == '\0') { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing path"); return ESP_OK; } if (!path_is_safe(path)) { httpd_resp_send_err(req, HTTPD_403_FORBIDDEN, "Invalid path"); return ESP_OK; } bool is_telemetry = (strcmp(path, TELEMETRY_FILE) == 0); if (is_telemetry) { s_telemetry_attempts++; } size_t file_size = 0; if (sd_card_get_file_size(path, &file_size) != ESP_OK) { httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "File not found"); return ESP_OK; } httpd_resp_set_type(req, "application/octet-stream"); httpd_resp_set_hdr(req, "Content-Disposition", "attachment"); static uint8_t buf[SDCARD_HTTP_CHUNK]; size_t offset = 0; while (offset < file_size) { size_t to_read = file_size - offset; if (to_read > sizeof(buf)) to_read = sizeof(buf); size_t n = 0; if (sd_card_read_file_at(path, offset, buf, to_read, &n) != ESP_OK || n == 0) { break; } if (httpd_resp_send_chunk(req, (char *)buf, n) != ESP_OK) { return ESP_FAIL; } offset += n; } if (httpd_resp_send_chunk(req, NULL, 0) != ESP_OK) { return ESP_FAIL; } if (is_telemetry) { s_telemetry_downloads++; save_telemetry_status(file_size); if (CONFIG_TELEMETRY_AUTO_DELETE_AFTER_DOWNLOAD) { sd_card_delete_file(TELEMETRY_FILE); } } return ESP_OK; } esp_err_t sdcard_http_start(void) { if (s_server != NULL) { return ESP_OK; } load_telemetry_status(); httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.server_port = 8080; config.max_uri_handlers = 8; config.max_open_sockets = 4; config.uri_match_fn = httpd_uri_match_wildcard; if (httpd_start(&s_server, &config) != ESP_OK) { ESP_LOGE(TAG, "Failed to start HTTP server"); return ESP_FAIL; } httpd_uri_t sdcard_uri = { .uri = "/sdcard/*", .method = HTTP_GET, .handler = sdcard_file_handler, .user_ctx = NULL, }; if (httpd_register_uri_handler(s_server, &sdcard_uri) != ESP_OK) { httpd_stop(s_server); s_server = NULL; ESP_LOGE(TAG, "Failed to register /sdcard/* handler"); return ESP_FAIL; } ESP_LOGI(TAG, "HTTP server on port 8080: GET http://:8080/sdcard/"); return ESP_OK; } void sdcard_http_get_telemetry_stats(uint32_t *attempts, uint32_t *downloads) { if (attempts) *attempts = s_telemetry_attempts; if (downloads) *downloads = s_telemetry_downloads; } void sdcard_http_stop(void) { if (s_server) { httpd_stop(s_server); s_server = NULL; ESP_LOGI(TAG, "HTTP server stopped"); } }