401 lines
13 KiB
C
401 lines
13 KiB
C
/*
|
|
be_webfiles_lib.c - Static file server for Berry using ESP-IDF HTTP server
|
|
|
|
Copyright (C) 2025 Jonathan E. Peace
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU General Public License as published by
|
|
the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU General Public License for more details.
|
|
|
|
You should have received a copy of the GNU General Public License
|
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
#ifdef USE_BERRY_WEBFILES
|
|
|
|
#ifndef LOG_LOCAL_LEVEL
|
|
#define LOG_LOCAL_LEVEL ESP_LOG_INFO
|
|
#endif
|
|
|
|
#include "be_constobj.h"
|
|
#include "be_mapping.h"
|
|
|
|
// ESP-IDF includes
|
|
#include "esp_log.h"
|
|
#include "esp_http_server.h"
|
|
#include "esp_vfs.h"
|
|
|
|
// External Berry/Tasmota includes
|
|
extern httpd_handle_t be_httpserver_get_handle(void);
|
|
extern bool httpserver_queue_message(int type, int client_id,
|
|
const char* data, size_t len, void* user_data);
|
|
|
|
// Tag for logging
|
|
static const char *TAG = "WEBFILES";
|
|
|
|
// Default base path for files
|
|
static char base_path[64] = "/files";
|
|
|
|
// URI prefix for the file server
|
|
static char uri_prefix[32] = "/";
|
|
|
|
// Maximum file path length
|
|
#define FILE_PATH_MAX 128
|
|
|
|
// Scratch buffer size for file transfer
|
|
#define SCRATCH_BUFSIZE 4096 // 4KB scratch buffer for chunks
|
|
|
|
// Static buffer for file sending - fixed allocation
|
|
static char scratch_buffer[SCRATCH_BUFSIZE];
|
|
|
|
// MIME Type Mapping
|
|
static const struct {
|
|
const char *extension;
|
|
const char *mimetype;
|
|
} mime_types[] = {
|
|
{".html", "text/html"},
|
|
{".htm", "text/html"},
|
|
{".js", "application/javascript"},
|
|
{".mjs", "application/javascript"}, // ES modules
|
|
{".css", "text/css"},
|
|
{".png", "image/png"},
|
|
{".jpg", "image/jpeg"},
|
|
{".jpeg", "image/jpeg"},
|
|
{".gif", "image/gif"},
|
|
{".ico", "image/x-icon"},
|
|
{".svg", "image/svg+xml"},
|
|
{".json", "application/json"},
|
|
{".txt", "text/plain"},
|
|
{".md", "text/markdown"},
|
|
{".wasm", "application/wasm"}, // WebAssembly
|
|
{".map", "application/json"}, // Source maps
|
|
{".woff", "font/woff"},
|
|
{".woff2", "font/woff2"},
|
|
{".ttf", "font/ttf"},
|
|
{".otf", "font/otf"},
|
|
{".bin", "application/octet-stream"},
|
|
{NULL, NULL}
|
|
};
|
|
|
|
// Get MIME type based on file extension
|
|
static const char* get_mime_type(const char *path) {
|
|
const char *ext = strrchr(path, '.');
|
|
if (!ext) return "text/plain";
|
|
|
|
for (int i = 0; mime_types[i].extension; i++) {
|
|
if (strcasecmp(mime_types[i].extension, ext) == 0) {
|
|
return mime_types[i].mimetype;
|
|
}
|
|
}
|
|
return "text/plain";
|
|
}
|
|
|
|
// Build full path including base path
|
|
static const char* get_full_path(char *dest, const char *uri, size_t destsize) {
|
|
size_t base_len = strlen(base_path);
|
|
size_t prefix_len = strlen(uri_prefix);
|
|
size_t uri_len = strlen(uri);
|
|
|
|
// Handle query parameters and fragments in URI
|
|
const char *query = strchr(uri, '?');
|
|
if (query) {
|
|
uri_len = query - uri;
|
|
}
|
|
const char *fragment = strchr(uri, '#');
|
|
if (fragment && (!query || fragment < query)) {
|
|
uri_len = fragment - uri;
|
|
}
|
|
|
|
// Skip the URI prefix to get the relative path
|
|
const char *relative_path = uri;
|
|
if (prefix_len > 1 && strncmp(uri, uri_prefix, prefix_len) == 0) {
|
|
relative_path = uri + prefix_len - 1; // -1 because we want to keep the leading slash
|
|
uri_len -= (prefix_len - 1);
|
|
}
|
|
|
|
// Check if path will fit in destination buffer
|
|
if (base_len + uri_len + 1 > destsize) {
|
|
ESP_LOGE(TAG, "Path too long");
|
|
return NULL;
|
|
}
|
|
|
|
// Construct full path
|
|
strcpy(dest, base_path);
|
|
if (base_len > 0 && base_path[base_len-1] == '/' && relative_path[0] == '/') {
|
|
// Avoid double slash
|
|
strlcpy(dest + base_len, relative_path + 1, uri_len);
|
|
} else {
|
|
strlcpy(dest + base_len, relative_path, uri_len + 1);
|
|
}
|
|
|
|
return dest;
|
|
}
|
|
|
|
// Set content type based on file extension
|
|
static void set_content_type_from_file(httpd_req_t *req, const char *filepath) {
|
|
const char* mime_type = get_mime_type(filepath);
|
|
httpd_resp_set_type(req, mime_type);
|
|
|
|
// Set Cache-Control header for static assets
|
|
// Don't cache HTML, but cache other static assets
|
|
if (strstr(mime_type, "text/html") == NULL) {
|
|
// Cache for 1 hour (3600 seconds)
|
|
httpd_resp_set_hdr(req, "Cache-Control", "max-age=3600");
|
|
} else {
|
|
// Don't cache HTML content
|
|
httpd_resp_set_hdr(req, "Cache-Control", "no-cache");
|
|
}
|
|
|
|
// Add CORS headers for development convenience
|
|
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
|
|
}
|
|
|
|
//checks if a .html, .css, or .js file exists with a .br suffix and serve that Brotli-compressed version instead
|
|
|
|
static esp_err_t webfiles_handler(httpd_req_t *req) {
|
|
char filepath[FILE_PATH_MAX];
|
|
char compressed_filepath[FILE_PATH_MAX];
|
|
FILE *file = NULL;
|
|
struct stat file_stat;
|
|
bool use_compression = false;
|
|
const char* compression_type = NULL; // Only Gzip for now
|
|
|
|
// Process any URL query parameters if needed
|
|
char *query = strchr(req->uri, '?');
|
|
if (query) {
|
|
ESP_LOGI(TAG, "Request has query params: %s", query);
|
|
*query = '\0'; // Temporarily terminate URI at the query string for path resolution
|
|
}
|
|
|
|
// Get the full file path from the URI
|
|
if (get_full_path(filepath, req->uri, sizeof(filepath)) == NULL) {
|
|
ESP_LOGE(TAG, "Failed to get file path for URI: %s", req->uri);
|
|
httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "File not found");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
// Restore query string if we modified it
|
|
if (query) *query = '?';
|
|
|
|
ESP_LOGI(TAG, "Requested file: %s", filepath);
|
|
|
|
// Check if file is .html, .css, .js, or .svg and if a compressed version exists
|
|
const char *ext = strrchr(filepath, '.');
|
|
if (ext && (strcasecmp(ext, ".html") == 0 || strcasecmp(ext, ".css") == 0 ||
|
|
strcasecmp(ext, ".js") == 0 || strcasecmp(ext, ".svg") == 0)) {
|
|
|
|
// Check what compression formats the client supports
|
|
char accept_encoding[64] = {0};
|
|
if (httpd_req_get_hdr_value_str(req, "Accept-Encoding", accept_encoding, sizeof(accept_encoding)) == ESP_OK) {
|
|
ESP_LOGI(TAG, "Client supports compression: %s", accept_encoding);
|
|
if (!use_compression && strstr(accept_encoding, "gzip") != NULL) {
|
|
// Construct Gzip filepath
|
|
snprintf(compressed_filepath, sizeof(compressed_filepath), "%s.gz", filepath);
|
|
if (stat(compressed_filepath, &file_stat) == 0 && S_ISREG(file_stat.st_mode)) {
|
|
use_compression = true;
|
|
compression_type = "gzip";
|
|
strcpy(filepath, compressed_filepath); // Use the .gz file
|
|
ESP_LOGI(TAG, "Found Gzip version: %s", filepath);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if file exists
|
|
if (stat(filepath, &file_stat) != 0) {
|
|
ESP_LOGE(TAG, "File does not exist: %s", filepath);
|
|
httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "File not found");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
// Check if it's a regular file
|
|
if (!S_ISREG(file_stat.st_mode)) {
|
|
ESP_LOGE(TAG, "Not a regular file: %s", filepath);
|
|
httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Not a file");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
// Open the file for reading
|
|
file = fopen(filepath, "r");
|
|
if (!file) {
|
|
ESP_LOGE(TAG, "Failed to open file: %s", filepath);
|
|
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to open file");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
// Set content type based on file extension (use original path for MIME type if compressed)
|
|
char original_filepath[FILE_PATH_MAX];
|
|
if (use_compression) {
|
|
// Strip compression extension for MIME type detection
|
|
strcpy(original_filepath, filepath);
|
|
char* dot_pos = strrchr(original_filepath, '.');
|
|
if (dot_pos) {
|
|
*dot_pos = '\0'; // Remove compression extension
|
|
}
|
|
set_content_type_from_file(req, original_filepath);
|
|
} else {
|
|
set_content_type_from_file(req, filepath);
|
|
}
|
|
|
|
// Set compression headers if applicable
|
|
if (use_compression) {
|
|
httpd_resp_set_hdr(req, "Content-Encoding", compression_type);
|
|
httpd_resp_set_hdr(req, "Vary", "Accept-Encoding");
|
|
}
|
|
|
|
// Send file in chunks for efficiency
|
|
size_t chunk_size;
|
|
size_t total_sent = 0;
|
|
|
|
while ((chunk_size = fread(scratch_buffer, 1, SCRATCH_BUFSIZE, file)) > 0) {
|
|
if (httpd_resp_send_chunk(req, scratch_buffer, chunk_size) != ESP_OK) {
|
|
ESP_LOGE(TAG, "File send failed");
|
|
fclose(file);
|
|
httpd_resp_send_chunk(req, NULL, 0);
|
|
return ESP_FAIL;
|
|
}
|
|
total_sent += chunk_size;
|
|
}
|
|
|
|
// Close file
|
|
fclose(file);
|
|
|
|
// Finish the HTTP response
|
|
httpd_resp_send_chunk(req, NULL, 0);
|
|
ESP_LOGI(TAG, "File sent successfully (%d bytes, %s)", (int)total_sent, use_compression ? compression_type : "uncompressed");
|
|
|
|
return ESP_OK;
|
|
}
|
|
|
|
/****************************************************************
|
|
* Berry Interface Functions
|
|
****************************************************************/
|
|
|
|
// webfiles.serve(base_path, uri_prefix) -> bool
|
|
// Serve files from base_path at uri_prefix
|
|
static int w_webfiles_serve(bvm *vm) {
|
|
int initial_top = be_top(vm);
|
|
|
|
if (be_top(vm) >= 2 && be_isstring(vm, 1) && be_isstring(vm, 2)) {
|
|
const char* path = be_tostring(vm, 1);
|
|
const char* prefix = be_tostring(vm, 2);
|
|
|
|
ESP_LOGI(TAG, "Setting up file server with base path: %s, uri prefix: %s", path, prefix);
|
|
|
|
struct stat st;
|
|
if (stat(path, &st) != 0 || !S_ISDIR(st.st_mode)) {
|
|
ESP_LOGE(TAG, "Input path is not a valid directory: %s", path);
|
|
be_pushbool(vm, false);
|
|
// Clean up stack before returning
|
|
while (be_top(vm) > initial_top) {
|
|
be_pop(vm, 1);
|
|
}
|
|
be_return (vm); // Return directly
|
|
}
|
|
|
|
httpd_handle_t server = be_httpserver_get_handle();
|
|
if (!server) {
|
|
ESP_LOGE(TAG, "HTTP server not running");
|
|
be_pushbool(vm, false);
|
|
while (be_top(vm) > initial_top) {
|
|
be_pop(vm, 1);
|
|
}
|
|
be_return (vm);
|
|
}
|
|
|
|
strlcpy(base_path, path, sizeof(base_path));
|
|
strlcpy(uri_prefix, prefix, sizeof(uri_prefix));
|
|
|
|
|
|
// Use a static buffer for the URI pattern
|
|
static char registered_uri_pattern[64]; // ADD static keyword
|
|
// Ensure it's null-terminated even if snprintf truncates
|
|
registered_uri_pattern[sizeof(registered_uri_pattern) - 1] = '\0';
|
|
snprintf(registered_uri_pattern, sizeof(registered_uri_pattern), "%s*", prefix);
|
|
|
|
ESP_LOGI(TAG, "Registering URI handler with pattern: %s", registered_uri_pattern);
|
|
|
|
httpd_uri_t uri_handler = {
|
|
// Point to the static buffer
|
|
.uri = registered_uri_pattern, // Use the static buffer
|
|
.method = HTTP_GET,
|
|
.handler = webfiles_handler,
|
|
.is_websocket = false,
|
|
.user_ctx = NULL
|
|
};
|
|
|
|
esp_err_t ret = httpd_register_uri_handler(server, &uri_handler);
|
|
if (ret != ESP_OK) {
|
|
ESP_LOGE(TAG, "Failed to register URI handler: %d", ret);
|
|
be_pushbool(vm, false);
|
|
be_pop(vm, be_top(vm) - initial_top);
|
|
be_return (vm);
|
|
}
|
|
|
|
be_pushbool(vm, true);
|
|
be_pop(vm, be_top(vm) - initial_top);
|
|
be_return (vm);
|
|
}
|
|
|
|
be_pushbool(vm, false);
|
|
be_pop(vm, be_top(vm) - initial_top);
|
|
be_return (vm);
|
|
}
|
|
|
|
// webfiles.serve_file(file_path, uri) -> bool
|
|
// Serve a specific file at a specific URI
|
|
static int w_webfiles_serve_file(bvm *vm) {
|
|
int initial_top = be_top(vm);
|
|
|
|
if (be_top(vm) >= 2 && be_isstring(vm, 1) && be_isstring(vm, 2)) {
|
|
const char* file_path = be_tostring(vm, 1);
|
|
const char* uri = be_tostring(vm, 2);
|
|
|
|
// Check if file exists
|
|
struct stat file_stat;
|
|
if (stat(file_path, &file_stat) == -1) {
|
|
ESP_LOGE(TAG, "File not found: %s", file_path);
|
|
be_pushbool(vm, false);
|
|
be_pop(vm, be_top(vm) - initial_top);
|
|
be_return (vm);
|
|
}
|
|
|
|
// TODO: Implement custom handler for specific files
|
|
// This would require keeping track of file mappings
|
|
|
|
ESP_LOGW(TAG, "serve_file not yet implemented");
|
|
be_pushbool(vm, false);
|
|
be_pop(vm, be_top(vm) - initial_top);
|
|
be_return (vm);
|
|
}
|
|
|
|
be_pushbool(vm, false);
|
|
be_pop(vm, be_top(vm) - initial_top);
|
|
be_return (vm);
|
|
}
|
|
|
|
// Module definition
|
|
/* @const_object_info_begin
|
|
module webfiles (scope: global, strings: weak) {
|
|
serve, func(w_webfiles_serve)
|
|
serve_file, func(w_webfiles_serve_file)
|
|
|
|
// MIME type constants
|
|
MIME_HTML, str("text/html")
|
|
MIME_JS, str("application/javascript")
|
|
MIME_CSS, str("text/css")
|
|
MIME_JSON, str("application/json")
|
|
MIME_TEXT, str("text/plain")
|
|
MIME_BINARY, str("application/octet-stream")
|
|
}
|
|
@const_object_info_end */
|
|
#include "be_fixed_webfiles.h"
|
|
|
|
#endif // USE_BERRY_WEBFILES
|