266 lines
8.1 KiB
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");
|
|
}
|
|
}
|