ESP32/components/sdcard_http/sdcard_http.c

266 lines
8.1 KiB
C

/*
* 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/<path>.
* Use: wget http://<device-ip>: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 <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <inttypes.h>
#include <sys/stat.h>
#include <time.h>
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://<ip>:8080/sdcard/<path>");
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");
}
}