mirror of
https://github.com/m5stack/ESP-Claw.git
synced 2026-05-20 11:51:49 -07:00
908 lines
31 KiB
C
908 lines
31 KiB
C
/*
|
|
* SPDX-FileCopyrightText: 2026 Espressif Systems (Shanghai) CO LTD
|
|
*
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
#include "config_http_server.h"
|
|
|
|
#include <dirent.h>
|
|
#include <errno.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <sys/stat.h>
|
|
#include <unistd.h>
|
|
|
|
#include "basic_demo_settings.h"
|
|
#include "basic_demo_wifi.h"
|
|
#include "cap_im_wechat.h"
|
|
#include "cJSON.h"
|
|
#include "esp_check.h"
|
|
#include "esp_heap_caps.h"
|
|
#include "esp_http_server.h"
|
|
#include "esp_log.h"
|
|
|
|
static const char *TAG = "config_http";
|
|
|
|
#define CONFIG_HTTP_CTRL_PORT 32769
|
|
#define CONFIG_HTTP_SCRATCH_SIZE 4096
|
|
#define CONFIG_HTTP_PATH_MAX 256
|
|
#define CONFIG_HTTP_UPLOAD_MAX_SIZE (512 * 1024)
|
|
|
|
extern const uint8_t index_html_start[] asm("_binary_index_html_start");
|
|
extern const uint8_t index_html_end[] asm("_binary_index_html_end");
|
|
extern const uint8_t styles_css_start[] asm("_binary_styles_css_start");
|
|
extern const uint8_t styles_css_end[] asm("_binary_styles_css_end");
|
|
extern const uint8_t app_js_start[] asm("_binary_app_js_start");
|
|
extern const uint8_t app_js_end[] asm("_binary_app_js_end");
|
|
extern const uint8_t lean_qr_min_mjs_start[] asm("_binary_lean_qr_min_mjs_start");
|
|
extern const uint8_t lean_qr_min_mjs_end[] asm("_binary_lean_qr_min_mjs_end");
|
|
|
|
typedef struct {
|
|
httpd_handle_t server;
|
|
char storage_base_path[CONFIG_HTTP_PATH_MAX];
|
|
} config_http_server_ctx_t;
|
|
|
|
static config_http_server_ctx_t s_ctx = {0};
|
|
|
|
static char *alloc_scratch_buffer(void)
|
|
{
|
|
return heap_caps_malloc_prefer(CONFIG_HTTP_SCRATCH_SIZE,
|
|
2,
|
|
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT,
|
|
MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
|
|
}
|
|
|
|
static bool path_is_safe(const char *path)
|
|
{
|
|
return path && path[0] == '/' && strstr(path, "..") == NULL;
|
|
}
|
|
|
|
static void url_decode_inplace(char *value)
|
|
{
|
|
if (!value) {
|
|
return;
|
|
}
|
|
|
|
char *src = value;
|
|
char *dst = value;
|
|
while (*src) {
|
|
if (src[0] == '%' && src[1] && src[2]) {
|
|
char hi = src[1];
|
|
char lo = src[2];
|
|
uint8_t decoded = 0;
|
|
|
|
if (hi >= '0' && hi <= '9') {
|
|
decoded = (uint8_t)(hi - '0') << 4;
|
|
} else if (hi >= 'A' && hi <= 'F') {
|
|
decoded = (uint8_t)(hi - 'A' + 10) << 4;
|
|
} else if (hi >= 'a' && hi <= 'f') {
|
|
decoded = (uint8_t)(hi - 'a' + 10) << 4;
|
|
} else {
|
|
*dst++ = *src++;
|
|
continue;
|
|
}
|
|
|
|
if (lo >= '0' && lo <= '9') {
|
|
decoded |= (uint8_t)(lo - '0');
|
|
} else if (lo >= 'A' && lo <= 'F') {
|
|
decoded |= (uint8_t)(lo - 'A' + 10);
|
|
} else if (lo >= 'a' && lo <= 'f') {
|
|
decoded |= (uint8_t)(lo - 'a' + 10);
|
|
} else {
|
|
*dst++ = *src++;
|
|
continue;
|
|
}
|
|
|
|
*dst++ = (char)decoded;
|
|
src += 3;
|
|
continue;
|
|
}
|
|
|
|
if (*src == '+') {
|
|
*dst++ = ' ';
|
|
src++;
|
|
continue;
|
|
}
|
|
|
|
*dst++ = *src++;
|
|
}
|
|
|
|
*dst = '\0';
|
|
}
|
|
|
|
static esp_err_t query_get(httpd_req_t *req, const char *key, char *value, size_t value_size)
|
|
{
|
|
size_t query_len = httpd_req_get_url_query_len(req);
|
|
if (query_len == 0) {
|
|
return ESP_ERR_NOT_FOUND;
|
|
}
|
|
|
|
char *query = calloc(1, query_len + 1);
|
|
if (!query) {
|
|
return ESP_ERR_NO_MEM;
|
|
}
|
|
|
|
esp_err_t err = httpd_req_get_url_query_str(req, query, query_len + 1);
|
|
if (err == ESP_OK) {
|
|
err = httpd_query_key_value(query, key, value, value_size);
|
|
if (err == ESP_OK) {
|
|
url_decode_inplace(value);
|
|
}
|
|
}
|
|
|
|
free(query);
|
|
return err;
|
|
}
|
|
|
|
static esp_err_t send_embedded_file(httpd_req_t *req,
|
|
const uint8_t *start,
|
|
const uint8_t *end,
|
|
const char *content_type)
|
|
{
|
|
size_t content_len = (size_t)(end - start);
|
|
if (content_len > 0 && start[content_len - 1] == '\0') {
|
|
content_len--;
|
|
}
|
|
httpd_resp_set_type(req, content_type);
|
|
httpd_resp_set_hdr(req, "Cache-Control", "no-store, max-age=0");
|
|
httpd_resp_set_hdr(req, "Pragma", "no-cache");
|
|
return httpd_resp_send(req, (const char *)start, content_len);
|
|
}
|
|
|
|
static void json_add_string(cJSON *root, const char *key, const char *value)
|
|
{
|
|
cJSON_AddStringToObject(root, key, value ? value : "");
|
|
}
|
|
|
|
static esp_err_t send_json_response(httpd_req_t *req, cJSON *root)
|
|
{
|
|
char *payload = NULL;
|
|
esp_err_t err;
|
|
|
|
if (!req || !root) {
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
|
|
payload = cJSON_PrintUnformatted(root);
|
|
cJSON_Delete(root);
|
|
if (!payload) {
|
|
httpd_resp_send_500(req);
|
|
return ESP_ERR_NO_MEM;
|
|
}
|
|
|
|
httpd_resp_set_type(req, "application/json");
|
|
httpd_resp_set_hdr(req, "Cache-Control", "no-store, max-age=0");
|
|
err = httpd_resp_sendstr(req, payload);
|
|
free(payload);
|
|
return err;
|
|
}
|
|
|
|
static esp_err_t settings_to_json(httpd_req_t *req)
|
|
{
|
|
basic_demo_settings_t settings;
|
|
ESP_RETURN_ON_ERROR(basic_demo_settings_load(&settings), TAG, "Failed to load settings");
|
|
|
|
cJSON *root = cJSON_CreateObject();
|
|
if (!root) {
|
|
httpd_resp_send_500(req);
|
|
return ESP_ERR_NO_MEM;
|
|
}
|
|
|
|
json_add_string(root, "wifi_ssid", settings.wifi_ssid);
|
|
json_add_string(root, "wifi_password", settings.wifi_password);
|
|
json_add_string(root, "llm_api_key", settings.llm_api_key);
|
|
json_add_string(root, "llm_backend_type", settings.llm_backend_type);
|
|
json_add_string(root, "llm_profile", settings.llm_profile);
|
|
json_add_string(root, "llm_model", settings.llm_model);
|
|
json_add_string(root, "llm_base_url", settings.llm_base_url);
|
|
json_add_string(root, "llm_auth_type", settings.llm_auth_type);
|
|
json_add_string(root, "llm_timeout_ms", settings.llm_timeout_ms);
|
|
json_add_string(root, "qq_app_id", settings.qq_app_id);
|
|
json_add_string(root, "qq_app_secret", settings.qq_app_secret);
|
|
json_add_string(root, "feishu_app_id", settings.feishu_app_id);
|
|
json_add_string(root, "feishu_app_secret", settings.feishu_app_secret);
|
|
json_add_string(root, "tg_bot_token", settings.tg_bot_token);
|
|
json_add_string(root, "wechat_token", settings.wechat_token);
|
|
json_add_string(root, "wechat_base_url", settings.wechat_base_url);
|
|
json_add_string(root, "wechat_cdn_base_url", settings.wechat_cdn_base_url);
|
|
json_add_string(root, "wechat_account_id", settings.wechat_account_id);
|
|
json_add_string(root, "search_brave_key", settings.search_brave_key);
|
|
json_add_string(root, "search_tavily_key", settings.search_tavily_key);
|
|
json_add_string(root, "time_timezone", settings.time_timezone);
|
|
|
|
char *payload = cJSON_PrintUnformatted(root);
|
|
cJSON_Delete(root);
|
|
if (!payload) {
|
|
httpd_resp_send_500(req);
|
|
return ESP_ERR_NO_MEM;
|
|
}
|
|
|
|
httpd_resp_set_type(req, "application/json");
|
|
httpd_resp_set_hdr(req, "Cache-Control", "no-store, max-age=0");
|
|
esp_err_t err = httpd_resp_sendstr(req, payload);
|
|
free(payload);
|
|
return err;
|
|
}
|
|
|
|
static esp_err_t parse_json_body(httpd_req_t *req, cJSON **out_root)
|
|
{
|
|
if (!out_root || req->content_len <= 0 || req->content_len > 8192) {
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
|
|
char *body = calloc(1, req->content_len + 1);
|
|
if (!body) {
|
|
return ESP_ERR_NO_MEM;
|
|
}
|
|
|
|
int received = 0;
|
|
while (received < req->content_len) {
|
|
int ret = httpd_req_recv(req, body + received, req->content_len - received);
|
|
if (ret <= 0) {
|
|
free(body);
|
|
return (ret == HTTPD_SOCK_ERR_TIMEOUT) ? ESP_ERR_TIMEOUT : ESP_FAIL;
|
|
}
|
|
received += ret;
|
|
}
|
|
|
|
cJSON *root = cJSON_Parse(body);
|
|
free(body);
|
|
if (!root) {
|
|
return ESP_ERR_INVALID_RESPONSE;
|
|
}
|
|
|
|
*out_root = root;
|
|
return ESP_OK;
|
|
}
|
|
|
|
static void json_read_string(cJSON *root, const char *key, char *buffer, size_t buffer_size)
|
|
{
|
|
cJSON *item = cJSON_GetObjectItemCaseSensitive(root, key);
|
|
if (cJSON_IsString(item)) {
|
|
strlcpy(buffer, item->valuestring, buffer_size);
|
|
}
|
|
}
|
|
|
|
static esp_err_t index_handler(httpd_req_t *req)
|
|
{
|
|
return send_embedded_file(req, index_html_start, index_html_end, "text/html; charset=utf-8");
|
|
}
|
|
|
|
static esp_err_t styles_handler(httpd_req_t *req)
|
|
{
|
|
return send_embedded_file(req, styles_css_start, styles_css_end, "text/css; charset=utf-8");
|
|
}
|
|
|
|
static esp_err_t app_js_handler(httpd_req_t *req)
|
|
{
|
|
return send_embedded_file(req, app_js_start, app_js_end, "application/javascript; charset=utf-8");
|
|
}
|
|
|
|
static esp_err_t lean_qr_handler(httpd_req_t *req)
|
|
{
|
|
return send_embedded_file(req,
|
|
lean_qr_min_mjs_start,
|
|
lean_qr_min_mjs_end,
|
|
"application/javascript; charset=utf-8");
|
|
}
|
|
|
|
static esp_err_t status_handler(httpd_req_t *req)
|
|
{
|
|
cJSON *root = cJSON_CreateObject();
|
|
if (!root) {
|
|
httpd_resp_send_500(req);
|
|
return ESP_ERR_NO_MEM;
|
|
}
|
|
|
|
cJSON_AddBoolToObject(root, "wifi_connected", basic_demo_wifi_is_connected());
|
|
json_add_string(root, "ip", basic_demo_wifi_get_ip());
|
|
json_add_string(root, "storage_base_path", s_ctx.storage_base_path);
|
|
cJSON_AddBoolToObject(root, "ap_active", basic_demo_wifi_is_ap_active());
|
|
json_add_string(root, "ap_ssid", basic_demo_wifi_get_ap_ssid());
|
|
json_add_string(root, "ap_ip", basic_demo_wifi_get_ap_ip());
|
|
json_add_string(root, "wifi_mode", basic_demo_wifi_get_mode_string());
|
|
|
|
char *payload = cJSON_PrintUnformatted(root);
|
|
cJSON_Delete(root);
|
|
if (!payload) {
|
|
httpd_resp_send_500(req);
|
|
return ESP_ERR_NO_MEM;
|
|
}
|
|
|
|
httpd_resp_set_type(req, "application/json");
|
|
httpd_resp_set_hdr(req, "Cache-Control", "no-store, max-age=0");
|
|
esp_err_t err = httpd_resp_sendstr(req, payload);
|
|
free(payload);
|
|
return err;
|
|
}
|
|
|
|
static esp_err_t config_get_handler(httpd_req_t *req)
|
|
{
|
|
return settings_to_json(req);
|
|
}
|
|
|
|
static esp_err_t config_post_handler(httpd_req_t *req)
|
|
{
|
|
basic_demo_settings_t settings;
|
|
ESP_RETURN_ON_ERROR(basic_demo_settings_load(&settings), TAG, "Failed to load settings");
|
|
|
|
cJSON *root = NULL;
|
|
esp_err_t err = parse_json_body(req, &root);
|
|
if (err != ESP_OK) {
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON body");
|
|
return err;
|
|
}
|
|
|
|
json_read_string(root, "wifi_ssid", settings.wifi_ssid, sizeof(settings.wifi_ssid));
|
|
json_read_string(root, "wifi_password", settings.wifi_password, sizeof(settings.wifi_password));
|
|
json_read_string(root, "llm_api_key", settings.llm_api_key, sizeof(settings.llm_api_key));
|
|
json_read_string(root, "llm_backend_type", settings.llm_backend_type, sizeof(settings.llm_backend_type));
|
|
json_read_string(root, "llm_profile", settings.llm_profile, sizeof(settings.llm_profile));
|
|
json_read_string(root, "llm_model", settings.llm_model, sizeof(settings.llm_model));
|
|
json_read_string(root, "llm_base_url", settings.llm_base_url, sizeof(settings.llm_base_url));
|
|
json_read_string(root, "llm_auth_type", settings.llm_auth_type, sizeof(settings.llm_auth_type));
|
|
json_read_string(root, "llm_timeout_ms", settings.llm_timeout_ms, sizeof(settings.llm_timeout_ms));
|
|
json_read_string(root, "qq_app_id", settings.qq_app_id, sizeof(settings.qq_app_id));
|
|
json_read_string(root, "qq_app_secret", settings.qq_app_secret, sizeof(settings.qq_app_secret));
|
|
json_read_string(root, "feishu_app_id", settings.feishu_app_id, sizeof(settings.feishu_app_id));
|
|
json_read_string(root, "feishu_app_secret", settings.feishu_app_secret, sizeof(settings.feishu_app_secret));
|
|
json_read_string(root, "tg_bot_token", settings.tg_bot_token, sizeof(settings.tg_bot_token));
|
|
json_read_string(root, "wechat_token", settings.wechat_token, sizeof(settings.wechat_token));
|
|
json_read_string(root, "wechat_base_url", settings.wechat_base_url, sizeof(settings.wechat_base_url));
|
|
json_read_string(root, "wechat_cdn_base_url", settings.wechat_cdn_base_url, sizeof(settings.wechat_cdn_base_url));
|
|
json_read_string(root, "wechat_account_id", settings.wechat_account_id, sizeof(settings.wechat_account_id));
|
|
json_read_string(root, "search_brave_key", settings.search_brave_key, sizeof(settings.search_brave_key));
|
|
json_read_string(root, "search_tavily_key", settings.search_tavily_key, sizeof(settings.search_tavily_key));
|
|
json_read_string(root, "time_timezone", settings.time_timezone, sizeof(settings.time_timezone));
|
|
|
|
cJSON_Delete(root);
|
|
|
|
err = basic_demo_settings_save(&settings);
|
|
if (err != ESP_OK) {
|
|
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to save settings");
|
|
return err;
|
|
}
|
|
|
|
httpd_resp_set_type(req, "application/json");
|
|
httpd_resp_set_hdr(req, "Cache-Control", "no-store, max-age=0");
|
|
return httpd_resp_sendstr(req, "{\"ok\":true,\"message\":\"Saved. Restart the device to apply Wi-Fi and core LLM changes.\"}");
|
|
}
|
|
|
|
static esp_err_t wechat_login_persist_if_needed(cap_im_wechat_qr_login_status_t *status)
|
|
{
|
|
basic_demo_settings_t settings;
|
|
esp_err_t err;
|
|
|
|
if (!status || !status->completed || status->persisted || !status->token[0]) {
|
|
return ESP_OK;
|
|
}
|
|
|
|
err = basic_demo_settings_load(&settings);
|
|
if (err != ESP_OK) {
|
|
return err;
|
|
}
|
|
|
|
strlcpy(settings.wechat_token, status->token, sizeof(settings.wechat_token));
|
|
strlcpy(settings.wechat_base_url,
|
|
status->base_url[0] ? status->base_url : settings.wechat_base_url,
|
|
sizeof(settings.wechat_base_url));
|
|
strlcpy(settings.wechat_account_id,
|
|
status->account_id[0] ? status->account_id : settings.wechat_account_id,
|
|
sizeof(settings.wechat_account_id));
|
|
|
|
err = basic_demo_settings_save(&settings);
|
|
if (err != ESP_OK) {
|
|
return err;
|
|
}
|
|
|
|
return cap_im_wechat_qr_login_mark_persisted();
|
|
}
|
|
|
|
static esp_err_t wechat_login_start_handler(httpd_req_t *req)
|
|
{
|
|
cJSON *root = NULL;
|
|
cJSON *resp = NULL;
|
|
const char *account_id = NULL;
|
|
bool force = false;
|
|
cap_im_wechat_qr_login_status_t status = {0};
|
|
esp_err_t err;
|
|
|
|
if (req->content_len > 0) {
|
|
err = parse_json_body(req, &root);
|
|
if (err != ESP_OK) {
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON body");
|
|
return err;
|
|
}
|
|
account_id = cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(root, "account_id"));
|
|
force = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(root, "force"));
|
|
}
|
|
|
|
err = cap_im_wechat_qr_login_start(account_id, force);
|
|
if (root) {
|
|
cJSON_Delete(root);
|
|
}
|
|
if (err != ESP_OK) {
|
|
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to start WeChat login");
|
|
return err;
|
|
}
|
|
|
|
ESP_RETURN_ON_ERROR(cap_im_wechat_qr_login_get_status(&status),
|
|
TAG,
|
|
"Failed to fetch WeChat login status");
|
|
resp = cJSON_CreateObject();
|
|
if (!resp) {
|
|
httpd_resp_send_500(req);
|
|
return ESP_ERR_NO_MEM;
|
|
}
|
|
|
|
cJSON_AddBoolToObject(resp, "ok", true);
|
|
json_add_string(resp, "session_key", status.session_key);
|
|
json_add_string(resp, "status", status.status);
|
|
json_add_string(resp, "message", status.message);
|
|
json_add_string(resp, "qr_data_url", status.qr_data_url);
|
|
return send_json_response(req, resp);
|
|
}
|
|
|
|
static esp_err_t wechat_login_status_handler(httpd_req_t *req)
|
|
{
|
|
cap_im_wechat_qr_login_status_t status = {0};
|
|
cJSON *resp = NULL;
|
|
esp_err_t err;
|
|
|
|
err = cap_im_wechat_qr_login_get_status(&status);
|
|
if (err != ESP_OK) {
|
|
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to read WeChat login status");
|
|
return err;
|
|
}
|
|
|
|
err = wechat_login_persist_if_needed(&status);
|
|
if (err == ESP_OK && status.completed && !status.persisted) {
|
|
status.persisted = true;
|
|
strlcpy(status.message,
|
|
"微信登录成功,凭据已保存。重启设备后生效。",
|
|
sizeof(status.message));
|
|
}
|
|
|
|
resp = cJSON_CreateObject();
|
|
if (!resp) {
|
|
httpd_resp_send_500(req);
|
|
return ESP_ERR_NO_MEM;
|
|
}
|
|
|
|
cJSON_AddBoolToObject(resp, "ok", true);
|
|
cJSON_AddBoolToObject(resp, "active", status.active);
|
|
cJSON_AddBoolToObject(resp, "completed", status.completed);
|
|
cJSON_AddBoolToObject(resp, "persisted", status.persisted);
|
|
cJSON_AddBoolToObject(resp, "configured", status.configured);
|
|
json_add_string(resp, "session_key", status.session_key);
|
|
json_add_string(resp, "status", status.status);
|
|
json_add_string(resp, "message", status.message);
|
|
json_add_string(resp, "qr_data_url", status.qr_data_url);
|
|
json_add_string(resp, "account_id", status.account_id);
|
|
json_add_string(resp, "user_id", status.user_id);
|
|
json_add_string(resp, "base_url", status.base_url);
|
|
cJSON_AddBoolToObject(resp, "restart_required", status.persisted);
|
|
return send_json_response(req, resp);
|
|
}
|
|
|
|
static esp_err_t wechat_login_cancel_handler(httpd_req_t *req)
|
|
{
|
|
cJSON *resp = NULL;
|
|
esp_err_t err = cap_im_wechat_qr_login_cancel();
|
|
|
|
if (err != ESP_OK) {
|
|
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to cancel WeChat login");
|
|
return err;
|
|
}
|
|
|
|
resp = cJSON_CreateObject();
|
|
if (!resp) {
|
|
httpd_resp_send_500(req);
|
|
return ESP_ERR_NO_MEM;
|
|
}
|
|
cJSON_AddBoolToObject(resp, "ok", true);
|
|
json_add_string(resp, "message", "已取消微信登录。");
|
|
return send_json_response(req, resp);
|
|
}
|
|
|
|
static esp_err_t resolve_storage_path(const char *relative_path, char *full_path, size_t full_path_size)
|
|
{
|
|
if (!path_is_safe(relative_path)) {
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
|
|
int written = snprintf(full_path, full_path_size, "%s%s", s_ctx.storage_base_path, relative_path);
|
|
if (written <= 0 || (size_t)written >= full_path_size) {
|
|
return ESP_ERR_INVALID_SIZE;
|
|
}
|
|
|
|
return ESP_OK;
|
|
}
|
|
|
|
static bool build_child_relative_path(const char *base_path,
|
|
const char *entry_name,
|
|
char *out_path,
|
|
size_t out_path_size)
|
|
{
|
|
if (!base_path || !entry_name || !out_path || out_path_size == 0) {
|
|
return false;
|
|
}
|
|
|
|
if (strcmp(base_path, "/") == 0) {
|
|
if (strlcpy(out_path, "/", out_path_size) >= out_path_size) {
|
|
return false;
|
|
}
|
|
} else if (strlcpy(out_path, base_path, out_path_size) >= out_path_size) {
|
|
return false;
|
|
}
|
|
|
|
if (strcmp(base_path, "/") != 0 && strlcat(out_path, "/", out_path_size) >= out_path_size) {
|
|
return false;
|
|
}
|
|
|
|
return strlcat(out_path, entry_name, out_path_size) < out_path_size;
|
|
}
|
|
|
|
static esp_err_t files_list_handler(httpd_req_t *req)
|
|
{
|
|
char relative_path[CONFIG_HTTP_PATH_MAX] = "/";
|
|
if (query_get(req, "path", relative_path, sizeof(relative_path)) != ESP_OK) {
|
|
strlcpy(relative_path, "/", sizeof(relative_path));
|
|
}
|
|
|
|
char full_path[CONFIG_HTTP_PATH_MAX];
|
|
if (resolve_storage_path(relative_path, full_path, sizeof(full_path)) != ESP_OK) {
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid path");
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
|
|
DIR *dir = opendir(full_path);
|
|
if (!dir) {
|
|
httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Directory not found");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
cJSON *root = cJSON_CreateObject();
|
|
cJSON *entries = cJSON_CreateArray();
|
|
if (!root || !entries) {
|
|
closedir(dir);
|
|
cJSON_Delete(root);
|
|
cJSON_Delete(entries);
|
|
httpd_resp_send_500(req);
|
|
return ESP_ERR_NO_MEM;
|
|
}
|
|
|
|
json_add_string(root, "path", relative_path);
|
|
cJSON_AddItemToObject(root, "entries", entries);
|
|
|
|
struct dirent *entry = NULL;
|
|
while ((entry = readdir(dir)) != NULL) {
|
|
if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) {
|
|
continue;
|
|
}
|
|
|
|
char child_relative[CONFIG_HTTP_PATH_MAX];
|
|
if (!build_child_relative_path(relative_path, entry->d_name, child_relative, sizeof(child_relative))) {
|
|
continue;
|
|
}
|
|
|
|
char child_full[CONFIG_HTTP_PATH_MAX];
|
|
if (resolve_storage_path(child_relative, child_full, sizeof(child_full)) != ESP_OK) {
|
|
continue;
|
|
}
|
|
|
|
struct stat st = {0};
|
|
if (stat(child_full, &st) != 0) {
|
|
continue;
|
|
}
|
|
|
|
cJSON *item = cJSON_CreateObject();
|
|
if (!item) {
|
|
continue;
|
|
}
|
|
|
|
json_add_string(item, "name", entry->d_name);
|
|
json_add_string(item, "path", child_relative);
|
|
cJSON_AddBoolToObject(item, "is_dir", S_ISDIR(st.st_mode));
|
|
cJSON_AddNumberToObject(item, "size", (double)st.st_size);
|
|
cJSON_AddItemToArray(entries, item);
|
|
}
|
|
|
|
closedir(dir);
|
|
|
|
char *payload = cJSON_PrintUnformatted(root);
|
|
cJSON_Delete(root);
|
|
if (!payload) {
|
|
httpd_resp_send_500(req);
|
|
return ESP_ERR_NO_MEM;
|
|
}
|
|
|
|
httpd_resp_set_type(req, "application/json");
|
|
httpd_resp_set_hdr(req, "Cache-Control", "no-store, max-age=0");
|
|
esp_err_t err = httpd_resp_sendstr(req, payload);
|
|
free(payload);
|
|
return err;
|
|
}
|
|
|
|
static esp_err_t file_download_handler(httpd_req_t *req)
|
|
{
|
|
const char *relative_path = req->uri + strlen("/files");
|
|
if (!path_is_safe(relative_path)) {
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid path");
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
|
|
char full_path[CONFIG_HTTP_PATH_MAX];
|
|
if (resolve_storage_path(relative_path, full_path, sizeof(full_path)) != ESP_OK) {
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid path");
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
|
|
struct stat st = {0};
|
|
if (stat(full_path, &st) != 0 || S_ISDIR(st.st_mode)) {
|
|
httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "File not found");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
FILE *file = fopen(full_path, "rb");
|
|
if (!file) {
|
|
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to open file");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
char *scratch = alloc_scratch_buffer();
|
|
if (!scratch) {
|
|
fclose(file);
|
|
httpd_resp_send_500(req);
|
|
return ESP_ERR_NO_MEM;
|
|
}
|
|
|
|
httpd_resp_set_type(req, "application/octet-stream");
|
|
httpd_resp_set_hdr(req, "Cache-Control", "no-store, max-age=0");
|
|
while (!feof(file)) {
|
|
size_t read_bytes = fread(scratch, 1, CONFIG_HTTP_SCRATCH_SIZE, file);
|
|
if (read_bytes > 0 && httpd_resp_send_chunk(req, scratch, read_bytes) != ESP_OK) {
|
|
free(scratch);
|
|
fclose(file);
|
|
return ESP_FAIL;
|
|
}
|
|
}
|
|
|
|
free(scratch);
|
|
fclose(file);
|
|
return httpd_resp_send_chunk(req, NULL, 0);
|
|
}
|
|
|
|
static esp_err_t files_upload_handler(httpd_req_t *req)
|
|
{
|
|
char relative_path[CONFIG_HTTP_PATH_MAX] = {0};
|
|
if (query_get(req, "path", relative_path, sizeof(relative_path)) != ESP_OK) {
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing path");
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
|
|
if (req->content_len <= 0 || req->content_len > CONFIG_HTTP_UPLOAD_MAX_SIZE) {
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid upload size");
|
|
return ESP_ERR_INVALID_SIZE;
|
|
}
|
|
|
|
char full_path[CONFIG_HTTP_PATH_MAX];
|
|
if (resolve_storage_path(relative_path, full_path, sizeof(full_path)) != ESP_OK) {
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid path");
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
|
|
char parent_path[CONFIG_HTTP_PATH_MAX];
|
|
strlcpy(parent_path, full_path, sizeof(parent_path));
|
|
char *slash = strrchr(parent_path, '/');
|
|
if (!slash || slash == parent_path) {
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid path");
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
*slash = '\0';
|
|
|
|
struct stat st = {0};
|
|
if (stat(parent_path, &st) != 0 || !S_ISDIR(st.st_mode)) {
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Parent directory not found");
|
|
return ESP_ERR_INVALID_STATE;
|
|
}
|
|
|
|
FILE *file = fopen(full_path, "wb");
|
|
if (!file) {
|
|
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to create file");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
char *scratch = alloc_scratch_buffer();
|
|
if (!scratch) {
|
|
fclose(file);
|
|
unlink(full_path);
|
|
httpd_resp_send_500(req);
|
|
return ESP_ERR_NO_MEM;
|
|
}
|
|
|
|
int remaining = req->content_len;
|
|
while (remaining > 0) {
|
|
int chunk = remaining > CONFIG_HTTP_SCRATCH_SIZE ? CONFIG_HTTP_SCRATCH_SIZE : remaining;
|
|
int received = httpd_req_recv(req, scratch, chunk);
|
|
if (received <= 0) {
|
|
free(scratch);
|
|
fclose(file);
|
|
unlink(full_path);
|
|
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Upload failed");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
if (fwrite(scratch, 1, received, file) != (size_t)received) {
|
|
free(scratch);
|
|
fclose(file);
|
|
unlink(full_path);
|
|
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Write failed");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
remaining -= received;
|
|
}
|
|
|
|
free(scratch);
|
|
fclose(file);
|
|
httpd_resp_set_type(req, "application/json");
|
|
httpd_resp_set_hdr(req, "Cache-Control", "no-store, max-age=0");
|
|
return httpd_resp_sendstr(req, "{\"ok\":true}");
|
|
}
|
|
|
|
static esp_err_t files_delete_handler(httpd_req_t *req)
|
|
{
|
|
char relative_path[CONFIG_HTTP_PATH_MAX] = {0};
|
|
if (query_get(req, "path", relative_path, sizeof(relative_path)) != ESP_OK) {
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing path");
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
|
|
char full_path[CONFIG_HTTP_PATH_MAX];
|
|
if (resolve_storage_path(relative_path, full_path, sizeof(full_path)) != ESP_OK) {
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid path");
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
|
|
struct stat st = {0};
|
|
if (stat(full_path, &st) != 0) {
|
|
httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Path not found");
|
|
return ESP_ERR_NOT_FOUND;
|
|
}
|
|
|
|
int rc = S_ISDIR(st.st_mode) ? rmdir(full_path) : unlink(full_path);
|
|
if (rc != 0) {
|
|
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Delete failed");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
httpd_resp_set_type(req, "application/json");
|
|
httpd_resp_set_hdr(req, "Cache-Control", "no-store, max-age=0");
|
|
return httpd_resp_sendstr(req, "{\"ok\":true}");
|
|
}
|
|
|
|
static esp_err_t files_mkdir_handler(httpd_req_t *req)
|
|
{
|
|
cJSON *root = NULL;
|
|
if (parse_json_body(req, &root) != ESP_OK) {
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid JSON body");
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
|
|
cJSON *path_item = cJSON_GetObjectItemCaseSensitive(root, "path");
|
|
if (!cJSON_IsString(path_item) || !path_is_safe(path_item->valuestring)) {
|
|
cJSON_Delete(root);
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid path");
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
|
|
char full_path[CONFIG_HTTP_PATH_MAX];
|
|
esp_err_t err = resolve_storage_path(path_item->valuestring, full_path, sizeof(full_path));
|
|
cJSON_Delete(root);
|
|
if (err != ESP_OK) {
|
|
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid path");
|
|
return err;
|
|
}
|
|
|
|
if (mkdir(full_path, 0775) != 0 && errno != EEXIST) {
|
|
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to create directory");
|
|
return ESP_FAIL;
|
|
}
|
|
|
|
httpd_resp_set_type(req, "application/json");
|
|
httpd_resp_set_hdr(req, "Cache-Control", "no-store, max-age=0");
|
|
return httpd_resp_sendstr(req, "{\"ok\":true}");
|
|
}
|
|
|
|
static esp_err_t captive_404_handler(httpd_req_t *req, httpd_err_code_t error)
|
|
{
|
|
if (!basic_demo_wifi_is_ap_active()) {
|
|
return httpd_resp_send_err(req, error, NULL);
|
|
}
|
|
|
|
const char *ap_ip = basic_demo_wifi_get_ap_ip();
|
|
if (!ap_ip || !ap_ip[0]) {
|
|
ap_ip = "192.168.4.1";
|
|
}
|
|
|
|
char location[40];
|
|
snprintf(location, sizeof(location), "http://%s/", ap_ip);
|
|
|
|
httpd_resp_set_status(req, "302 Found");
|
|
httpd_resp_set_hdr(req, "Location", location);
|
|
httpd_resp_set_hdr(req, "Connection", "close");
|
|
return httpd_resp_send(req, NULL, 0);
|
|
}
|
|
|
|
esp_err_t config_http_server_init(const char *storage_base_path)
|
|
{
|
|
if (!storage_base_path || storage_base_path[0] != '/') {
|
|
return ESP_ERR_INVALID_ARG;
|
|
}
|
|
|
|
strlcpy(s_ctx.storage_base_path, storage_base_path, sizeof(s_ctx.storage_base_path));
|
|
return ESP_OK;
|
|
}
|
|
|
|
esp_err_t config_http_server_start(void)
|
|
{
|
|
if (s_ctx.server) {
|
|
return ESP_OK;
|
|
}
|
|
|
|
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
|
|
config.server_port = BASIC_DEMO_HTTP_SERVER_PORT;
|
|
config.ctrl_port = CONFIG_HTTP_CTRL_PORT;
|
|
config.max_uri_handlers = 16;
|
|
config.stack_size = 8192;
|
|
config.uri_match_fn = httpd_uri_match_wildcard;
|
|
|
|
ESP_RETURN_ON_ERROR(httpd_start(&s_ctx.server, &config), TAG, "Failed to start HTTP server");
|
|
|
|
httpd_uri_t handlers[] = {
|
|
{ .uri = "/", .method = HTTP_GET, .handler = index_handler },
|
|
{ .uri = "/index.html", .method = HTTP_GET, .handler = index_handler },
|
|
{ .uri = "/styles.css", .method = HTTP_GET, .handler = styles_handler },
|
|
{ .uri = "/app.js", .method = HTTP_GET, .handler = app_js_handler },
|
|
{ .uri = "/lean-qr.min.mjs", .method = HTTP_GET, .handler = lean_qr_handler },
|
|
{ .uri = "/api/status", .method = HTTP_GET, .handler = status_handler },
|
|
{ .uri = "/api/config", .method = HTTP_GET, .handler = config_get_handler },
|
|
{ .uri = "/api/config", .method = HTTP_POST, .handler = config_post_handler },
|
|
{ .uri = "/api/wechat/login/start", .method = HTTP_POST, .handler = wechat_login_start_handler },
|
|
{ .uri = "/api/wechat/login/status", .method = HTTP_GET, .handler = wechat_login_status_handler },
|
|
{ .uri = "/api/wechat/login/cancel", .method = HTTP_POST, .handler = wechat_login_cancel_handler },
|
|
{ .uri = "/api/files", .method = HTTP_GET, .handler = files_list_handler },
|
|
{ .uri = "/api/files", .method = HTTP_DELETE, .handler = files_delete_handler },
|
|
{ .uri = "/api/files/upload", .method = HTTP_POST, .handler = files_upload_handler },
|
|
{ .uri = "/api/files/mkdir", .method = HTTP_POST, .handler = files_mkdir_handler },
|
|
{ .uri = "/files/*", .method = HTTP_GET, .handler = file_download_handler },
|
|
};
|
|
|
|
for (size_t i = 0; i < sizeof(handlers) / sizeof(handlers[0]); ++i) {
|
|
ESP_RETURN_ON_ERROR(httpd_register_uri_handler(s_ctx.server, &handlers[i]),
|
|
TAG,
|
|
"Failed to register URI handler");
|
|
}
|
|
|
|
ESP_RETURN_ON_ERROR(httpd_register_err_handler(s_ctx.server,
|
|
HTTPD_404_NOT_FOUND,
|
|
captive_404_handler),
|
|
TAG, "Failed to register captive 404 handler");
|
|
|
|
ESP_LOGI(TAG, "HTTP server started on port %d", BASIC_DEMO_HTTP_SERVER_PORT);
|
|
return ESP_OK;
|
|
}
|
|
|
|
esp_err_t config_http_server_stop(void)
|
|
{
|
|
if (!s_ctx.server) {
|
|
return ESP_OK;
|
|
}
|
|
|
|
ESP_RETURN_ON_ERROR(httpd_stop(s_ctx.server), TAG, "Failed to stop HTTP server");
|
|
s_ctx.server = NULL;
|
|
return ESP_OK;
|
|
}
|