diff --git a/idf_zacus/components/game_endpoint/game_endpoint.c b/idf_zacus/components/game_endpoint/game_endpoint.c index e5a5f28..c9520f4 100644 --- a/idf_zacus/components/game_endpoint/game_endpoint.c +++ b/idf_zacus/components/game_endpoint/game_endpoint.c @@ -872,6 +872,75 @@ void game_endpoint_get_puzzle_status(char *step_id, size_t step_cap, } } +// ─── POST /game/file?path=apps// — provision shell apps ──────────── +// Raw body → file under /littlefs/apps/ ONLY (whitelist + no traversal). +// Lets the GM push icon.png / step.txt for the display shell's dynamic tiles +// without reflashing a littlefs image. +#define GAME_ENDPOINT_MAX_FILE_BYTES (256 * 1024) + +static esp_err_t handle_file_post(httpd_req_t *req) { + char query[160], path_param[96]; + if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK || + httpd_query_key_value(query, "path", path_param, + sizeof(path_param)) != ESP_OK) { + return send_error(req, "400 Bad Request", "missing path param"); + } + if (strncmp(path_param, "apps/", 5) != 0 || strstr(path_param, "..") || + path_param[strlen(path_param) - 1] == '/') { + return send_error(req, "403 Forbidden", "path must be under apps/"); + } + if (req->content_len <= 0 || + req->content_len > GAME_ENDPOINT_MAX_FILE_BYTES) { + return send_error(req, "400 Bad Request", "size 1..262144 bytes"); + } + if (mount_storage_lazy() != ESP_OK) { + return send_error(req, "503 Service Unavailable", "storage_unavailable"); + } + + char full[160]; + snprintf(full, sizeof(full), "/littlefs/%s", path_param); + // mkdir -p for intermediate directories. + for (char *p = full + strlen("/littlefs/"); *p; p++) { + if (*p == '/') { + *p = '\0'; + mkdir(full, 0775); // EEXIST is fine + *p = '/'; + } + } + + FILE *f = fopen(full, "wb"); + if (!f) { + return send_error(req, "500 Internal Server Error", "open failed"); + } + char buf[1024]; + int remaining = (int) req->content_len; + while (remaining > 0) { + const int want = remaining < (int) sizeof(buf) ? remaining + : (int) sizeof(buf); + int got = httpd_req_recv(req, buf, want); + if (got <= 0) { + if (got == HTTPD_SOCK_ERR_TIMEOUT) continue; + fclose(f); + unlink(full); + return send_error(req, "400 Bad Request", "recv failed"); + } + if (fwrite(buf, 1, (size_t) got, f) != (size_t) got) { + fclose(f); + unlink(full); + return send_error(req, "500 Internal Server Error", "write failed"); + } + remaining -= got; + } + fclose(f); + + char resp[192]; + snprintf(resp, sizeof(resp), + "{\"status\":\"ok\",\"path\":\"%s\",\"bytes\":%d}", + path_param, (int) req->content_len); + ESP_LOGI(TAG, "file stored: %s (%d B)", full, (int) req->content_len); + return send_json(req, "200 OK", resp); +} + void game_endpoint_get_scene(scene_binding_t *out) { if (!out) return; // Lock-free snapshot, same class as get_puzzle_status: written on the @@ -937,6 +1006,12 @@ esp_err_t game_endpoint_init(httpd_handle_t server) { .handler = handle_puzzle_state_get, .user_ctx = NULL, }; + static const httpd_uri_t uri_file_post = { + .uri = "/game/file", + .method = HTTP_POST, + .handler = handle_file_post, + .user_ctx = NULL, + }; // Bring up the ESP-NOW mesh transport. The master is primarily a sender // (the relay handler) but we also register the apply adapter so a peer @@ -990,6 +1065,10 @@ esp_err_t game_endpoint_init(httpd_handle_t server) { if (err != ESP_OK) { ESP_LOGW(TAG, "register POST /game/step: %s", esp_err_to_name(err)); } + err = httpd_register_uri_handler(server, &uri_file_post); + if (err != ESP_OK) { + ESP_LOGW(TAG, "register /game/file: %s", esp_err_to_name(err)); + } err = httpd_register_uri_handler(server, &uri_puzzle_state_get); if (err != ESP_OK) { ESP_LOGW(TAG, "register GET /game/puzzle_state: %s", esp_err_to_name(err)); diff --git a/idf_zacus/components/ota_server/ota_server.c b/idf_zacus/components/ota_server/ota_server.c index 5b260c9..0754dd2 100644 --- a/idf_zacus/components/ota_server/ota_server.c +++ b/idf_zacus/components/ota_server/ota_server.c @@ -310,8 +310,9 @@ esp_err_t ota_server_init(void) { // HTTP server config httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.server_port = OTA_SERVER_PORT; - config.max_uri_handlers = 18; // ota + voice_hook + game (incl. /game/step, - // /game/puzzle_state, relay) + headroom + config.max_uri_handlers = 20; // ota + voice_hook + game (incl. /game/step, + // /game/puzzle_state, /game/file, relay) + // + headroom config.uri_match_fn = httpd_uri_match_wildcard; config.stack_size = 8192;