mirror of
https://github.com/esphome/esphome.git
synced 2026-01-10 04:00:51 -07:00
[web_server] Fix URL collisions with UTF-8 names and sub-devices (#12627)
This commit is contained in:
@@ -45,62 +45,144 @@ static constexpr size_t PSTR_LOCAL_SIZE = 18;
|
||||
#define PSTR_LOCAL(mode_s) ESPHOME_strncpy_P(buf, (ESPHOME_PGM_P) ((mode_s)), PSTR_LOCAL_SIZE - 1)
|
||||
|
||||
// Parse URL and return match info
|
||||
static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain) {
|
||||
UrlMatch match{};
|
||||
// URL formats (disambiguated by HTTP method for 3-segment case):
|
||||
// GET /{domain}/{entity_name} - main device state
|
||||
// POST /{domain}/{entity_name}/{action} - main device action
|
||||
// GET /{domain}/{device_name}/{entity_name} - sub-device state (USE_DEVICES only)
|
||||
// POST /{domain}/{device_name}/{entity_name}/{action} - sub-device action (USE_DEVICES only)
|
||||
static UrlMatch match_url(const char *url_ptr, size_t url_len, bool only_domain, bool is_post = false) {
|
||||
// URL must start with '/' and have content after it
|
||||
if (url_len < 2 || url_ptr[0] != '/')
|
||||
return UrlMatch{};
|
||||
|
||||
// URL must start with '/'
|
||||
if (url_len < 2 || url_ptr[0] != '/') {
|
||||
return match;
|
||||
}
|
||||
|
||||
// Skip leading '/'
|
||||
const char *start = url_ptr + 1;
|
||||
const char *p = url_ptr + 1;
|
||||
const char *end = url_ptr + url_len;
|
||||
|
||||
// Find domain (everything up to next '/' or end)
|
||||
const char *domain_end = (const char *) memchr(start, '/', end - start);
|
||||
if (!domain_end) {
|
||||
// No second slash found - original behavior returns invalid
|
||||
return match;
|
||||
}
|
||||
// Helper to find next segment: returns pointer after '/' or nullptr if no more slashes
|
||||
auto next_segment = [&end](const char *start) -> const char * {
|
||||
const char *slash = (const char *) memchr(start, '/', end - start);
|
||||
return slash ? slash + 1 : nullptr;
|
||||
};
|
||||
|
||||
// Set domain
|
||||
match.domain = start;
|
||||
match.domain_len = domain_end - start;
|
||||
// Helper to make StringRef from segment start to next segment (or end)
|
||||
auto make_ref = [&end](const char *start, const char *next_start) -> StringRef {
|
||||
return StringRef(start, (next_start ? next_start - 1 : end) - start);
|
||||
};
|
||||
|
||||
// Parse domain segment
|
||||
const char *s1 = p;
|
||||
const char *s2 = next_segment(s1);
|
||||
|
||||
// Must have domain with trailing slash
|
||||
if (!s2)
|
||||
return UrlMatch{};
|
||||
|
||||
UrlMatch match{};
|
||||
match.domain = make_ref(s1, s2);
|
||||
match.valid = true;
|
||||
|
||||
if (only_domain) {
|
||||
if (only_domain || s2 >= end)
|
||||
return match;
|
||||
}
|
||||
|
||||
// Parse ID if present
|
||||
if (domain_end + 1 >= end) {
|
||||
return match; // Nothing after domain slash
|
||||
}
|
||||
// Parse remaining segments only when needed
|
||||
const char *s3 = next_segment(s2);
|
||||
const char *s4 = s3 ? next_segment(s3) : nullptr;
|
||||
|
||||
const char *id_start = domain_end + 1;
|
||||
const char *id_end = (const char *) memchr(id_start, '/', end - id_start);
|
||||
StringRef seg2 = make_ref(s2, s3);
|
||||
StringRef seg3 = s3 ? make_ref(s3, s4) : StringRef();
|
||||
StringRef seg4 = s4 ? make_ref(s4, nullptr) : StringRef();
|
||||
|
||||
if (!id_end) {
|
||||
// No more slashes, entire remaining string is ID
|
||||
match.id = id_start;
|
||||
match.id_len = end - id_start;
|
||||
return match;
|
||||
}
|
||||
// Reject empty segments
|
||||
if (seg2.empty() || (s3 && seg3.empty()) || (s4 && seg4.empty()))
|
||||
return UrlMatch{};
|
||||
|
||||
// Set ID
|
||||
match.id = id_start;
|
||||
match.id_len = id_end - id_start;
|
||||
|
||||
// Parse method if present
|
||||
if (id_end + 1 < end) {
|
||||
match.method = id_end + 1;
|
||||
match.method_len = end - (id_end + 1);
|
||||
// Interpret based on segment count
|
||||
if (!s3) {
|
||||
// 1 segment after domain: /{domain}/{entity}
|
||||
match.id = seg2;
|
||||
} else if (!s4) {
|
||||
// 2 segments after domain: /{domain}/{X}/{Y}
|
||||
// HTTP method disambiguates: GET = device/entity, POST = entity/action
|
||||
if (is_post) {
|
||||
match.id = seg2;
|
||||
match.method = seg3;
|
||||
return match;
|
||||
}
|
||||
#ifdef USE_DEVICES
|
||||
match.device_name = seg2;
|
||||
match.id = seg3;
|
||||
#else
|
||||
return UrlMatch{}; // 3-segment GET not supported without USE_DEVICES
|
||||
#endif
|
||||
} else {
|
||||
// 3 segments after domain: /{domain}/{device}/{entity}/{action}
|
||||
#ifdef USE_DEVICES
|
||||
if (!is_post) {
|
||||
return UrlMatch{}; // 4-segment GET not supported (action requires POST)
|
||||
}
|
||||
match.device_name = seg2;
|
||||
match.id = seg3;
|
||||
match.method = seg4;
|
||||
#else
|
||||
return UrlMatch{}; // Not supported without USE_DEVICES
|
||||
#endif
|
||||
}
|
||||
|
||||
return match;
|
||||
}
|
||||
|
||||
EntityMatchResult UrlMatch::match_entity(EntityBase *entity) const {
|
||||
EntityMatchResult result{false, this->method.empty()};
|
||||
|
||||
#ifdef USE_DEVICES
|
||||
Device *entity_device = entity->get_device();
|
||||
bool url_has_device = !this->device_name.empty();
|
||||
bool entity_has_device = (entity_device != nullptr);
|
||||
|
||||
// Device matching: URL device segment must match entity's device
|
||||
if (url_has_device != entity_has_device) {
|
||||
return result; // Mismatch: one has device, other doesn't
|
||||
}
|
||||
if (url_has_device && this->device_name != entity_device->get_name()) {
|
||||
return result; // Device name doesn't match
|
||||
}
|
||||
#endif
|
||||
|
||||
// Try matching by entity name (new format)
|
||||
if (this->id == entity->get_name()) {
|
||||
result.matched = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Fall back to object_id (deprecated format)
|
||||
char object_id_buf[OBJECT_ID_MAX_LEN];
|
||||
StringRef object_id = entity->get_object_id_to(object_id_buf);
|
||||
if (this->id == object_id) {
|
||||
result.matched = true;
|
||||
// Log deprecation warning
|
||||
#ifdef USE_DEVICES
|
||||
Device *device = entity->get_device();
|
||||
if (device != nullptr) {
|
||||
ESP_LOGW(TAG,
|
||||
"Deprecated URL format: /%.*s/%.*s/%.*s - use entity name '/%.*s/%s/%s' instead. "
|
||||
"Object ID URLs will be removed in 2026.7.0.",
|
||||
(int) this->domain.size(), this->domain.c_str(), (int) this->device_name.size(),
|
||||
this->device_name.c_str(), (int) this->id.size(), this->id.c_str(), (int) this->domain.size(),
|
||||
this->domain.c_str(), device->get_name(), entity->get_name().c_str());
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
ESP_LOGW(TAG,
|
||||
"Deprecated URL format: /%.*s/%.*s - use entity name '/%.*s/%s' instead. "
|
||||
"Object ID URLs will be removed in 2026.7.0.",
|
||||
(int) this->domain.size(), this->domain.c_str(), (int) this->id.size(), this->id.c_str(),
|
||||
(int) this->domain.size(), this->domain.c_str(), entity->get_name().c_str());
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
#if !defined(USE_ESP32) && defined(USE_ARDUINO)
|
||||
// helper for allowing only unique entries in the queue
|
||||
void DeferredUpdateEventSource::deq_push_back_with_dedup_(void *source, message_generator_t *message_generator) {
|
||||
@@ -397,15 +479,53 @@ void WebServer::handle_js_request(AsyncWebServerRequest *request) {
|
||||
#endif
|
||||
|
||||
// Helper functions to reduce code size by avoiding macro expansion
|
||||
// Build unique id as: {domain}/{device_name}/{entity_name} or {domain}/{entity_name}
|
||||
// Uses names (not object_id) to avoid UTF-8 collision issues
|
||||
static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, JsonDetail start_config) {
|
||||
char id_buf[160]; // prefix + dash + object_id (up to 128) + null
|
||||
size_t len = strlen(prefix);
|
||||
memcpy(id_buf, prefix, len); // NOLINT(bugprone-not-null-terminated-result) - null added by write_object_id_to
|
||||
id_buf[len++] = '-';
|
||||
obj->write_object_id_to(id_buf + len, sizeof(id_buf) - len);
|
||||
const StringRef &name = obj->get_name();
|
||||
size_t prefix_len = strlen(prefix);
|
||||
size_t name_len = name.size();
|
||||
|
||||
#ifdef USE_DEVICES
|
||||
Device *device = obj->get_device();
|
||||
const char *device_name = device ? device->get_name() : nullptr;
|
||||
size_t device_len = device_name ? strlen(device_name) : 0;
|
||||
#endif
|
||||
|
||||
// Build id into stack buffer - ArduinoJson copies the string
|
||||
// Format: {prefix}/{device?}/{name}
|
||||
// Buffer size guaranteed by schema validation (NAME_MAX_LENGTH=120):
|
||||
// With devices: domain(20) + "/" + device(120) + "/" + name(120) + null = 263, rounded up to 280 for safety margin
|
||||
// Without devices: domain(20) + "/" + name(120) + null = 142, rounded up to 150 for safety margin
|
||||
#ifdef USE_DEVICES
|
||||
char id_buf[280];
|
||||
#else
|
||||
char id_buf[150];
|
||||
#endif
|
||||
char *p = id_buf;
|
||||
memcpy(p, prefix, prefix_len);
|
||||
p += prefix_len;
|
||||
*p++ = '/';
|
||||
#ifdef USE_DEVICES
|
||||
if (device_name) {
|
||||
memcpy(p, device_name, device_len);
|
||||
p += device_len;
|
||||
*p++ = '/';
|
||||
}
|
||||
#endif
|
||||
memcpy(p, name.c_str(), name_len);
|
||||
p[name_len] = '\0';
|
||||
|
||||
root[ESPHOME_F("id")] = id_buf;
|
||||
|
||||
if (start_config == DETAIL_ALL) {
|
||||
root[ESPHOME_F("name")] = obj->get_name();
|
||||
root[ESPHOME_F("domain")] = prefix;
|
||||
root[ESPHOME_F("name")] = name;
|
||||
#ifdef USE_DEVICES
|
||||
if (device_name) {
|
||||
root[ESPHOME_F("device")] = device_name;
|
||||
}
|
||||
#endif
|
||||
root[ESPHOME_F("icon")] = obj->get_icon_ref();
|
||||
root[ESPHOME_F("entity_category")] = obj->get_entity_category();
|
||||
bool is_disabled = obj->is_disabled_by_default();
|
||||
@@ -444,10 +564,11 @@ void WebServer::on_sensor_update(sensor::Sensor *obj) {
|
||||
}
|
||||
void WebServer::handle_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||
for (sensor::Sensor *obj : App.get_sensors()) {
|
||||
if (!match.id_equals_entity(obj))
|
||||
auto entity_match = match.match_entity(obj);
|
||||
if (!entity_match.matched)
|
||||
continue;
|
||||
// Note: request->method() is always HTTP_GET here (canHandle ensures this)
|
||||
if (match.method_empty()) {
|
||||
if (entity_match.action_is_empty) {
|
||||
auto detail = get_request_detail(request);
|
||||
std::string data = this->sensor_json_(obj, obj->state, detail);
|
||||
request->send(200, "application/json", data.c_str());
|
||||
@@ -490,10 +611,11 @@ void WebServer::on_text_sensor_update(text_sensor::TextSensor *obj) {
|
||||
}
|
||||
void WebServer::handle_text_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||
for (text_sensor::TextSensor *obj : App.get_text_sensors()) {
|
||||
if (!match.id_equals_entity(obj))
|
||||
auto entity_match = match.match_entity(obj);
|
||||
if (!entity_match.matched)
|
||||
continue;
|
||||
// Note: request->method() is always HTTP_GET here (canHandle ensures this)
|
||||
if (match.method_empty()) {
|
||||
if (entity_match.action_is_empty) {
|
||||
auto detail = get_request_detail(request);
|
||||
std::string data = this->text_sensor_json_(obj, obj->state, detail);
|
||||
request->send(200, "application/json", data.c_str());
|
||||
@@ -532,10 +654,11 @@ void WebServer::on_switch_update(switch_::Switch *obj) {
|
||||
}
|
||||
void WebServer::handle_switch_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||
for (switch_::Switch *obj : App.get_switches()) {
|
||||
if (!match.id_equals_entity(obj))
|
||||
auto entity_match = match.match_entity(obj);
|
||||
if (!entity_match.matched)
|
||||
continue;
|
||||
|
||||
if (request->method() == HTTP_GET && match.method_empty()) {
|
||||
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
|
||||
auto detail = get_request_detail(request);
|
||||
std::string data = this->switch_json_(obj, obj->state, detail);
|
||||
request->send(200, "application/json", data.c_str());
|
||||
@@ -601,9 +724,10 @@ std::string WebServer::switch_json_(switch_::Switch *obj, bool value, JsonDetail
|
||||
#ifdef USE_BUTTON
|
||||
void WebServer::handle_button_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||
for (button::Button *obj : App.get_buttons()) {
|
||||
if (!match.id_equals_entity(obj))
|
||||
auto entity_match = match.match_entity(obj);
|
||||
if (!entity_match.matched)
|
||||
continue;
|
||||
if (request->method() == HTTP_GET && match.method_empty()) {
|
||||
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
|
||||
auto detail = get_request_detail(request);
|
||||
std::string data = this->button_json_(obj, detail);
|
||||
request->send(200, "application/json", data.c_str());
|
||||
@@ -645,10 +769,11 @@ void WebServer::on_binary_sensor_update(binary_sensor::BinarySensor *obj) {
|
||||
}
|
||||
void WebServer::handle_binary_sensor_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||
for (binary_sensor::BinarySensor *obj : App.get_binary_sensors()) {
|
||||
if (!match.id_equals_entity(obj))
|
||||
auto entity_match = match.match_entity(obj);
|
||||
if (!entity_match.matched)
|
||||
continue;
|
||||
// Note: request->method() is always HTTP_GET here (canHandle ensures this)
|
||||
if (match.method_empty()) {
|
||||
if (entity_match.action_is_empty) {
|
||||
auto detail = get_request_detail(request);
|
||||
std::string data = this->binary_sensor_json_(obj, obj->state, detail);
|
||||
request->send(200, "application/json", data.c_str());
|
||||
@@ -686,10 +811,11 @@ void WebServer::on_fan_update(fan::Fan *obj) {
|
||||
}
|
||||
void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||
for (fan::Fan *obj : App.get_fans()) {
|
||||
if (!match.id_equals_entity(obj))
|
||||
auto entity_match = match.match_entity(obj);
|
||||
if (!entity_match.matched)
|
||||
continue;
|
||||
|
||||
if (request->method() == HTTP_GET && match.method_empty()) {
|
||||
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
|
||||
auto detail = get_request_detail(request);
|
||||
std::string data = this->fan_json_(obj, detail);
|
||||
request->send(200, "application/json", data.c_str());
|
||||
@@ -766,10 +892,11 @@ void WebServer::on_light_update(light::LightState *obj) {
|
||||
}
|
||||
void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||
for (light::LightState *obj : App.get_lights()) {
|
||||
if (!match.id_equals_entity(obj))
|
||||
auto entity_match = match.match_entity(obj);
|
||||
if (!entity_match.matched)
|
||||
continue;
|
||||
|
||||
if (request->method() == HTTP_GET && match.method_empty()) {
|
||||
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
|
||||
auto detail = get_request_detail(request);
|
||||
std::string data = this->light_json_(obj, detail);
|
||||
request->send(200, "application/json", data.c_str());
|
||||
@@ -844,10 +971,11 @@ void WebServer::on_cover_update(cover::Cover *obj) {
|
||||
}
|
||||
void WebServer::handle_cover_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||
for (cover::Cover *obj : App.get_covers()) {
|
||||
if (!match.id_equals_entity(obj))
|
||||
auto entity_match = match.match_entity(obj);
|
||||
if (!entity_match.matched)
|
||||
continue;
|
||||
|
||||
if (request->method() == HTTP_GET && match.method_empty()) {
|
||||
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
|
||||
auto detail = get_request_detail(request);
|
||||
std::string data = this->cover_json_(obj, detail);
|
||||
request->send(200, "application/json", data.c_str());
|
||||
@@ -932,10 +1060,11 @@ void WebServer::on_number_update(number::Number *obj) {
|
||||
}
|
||||
void WebServer::handle_number_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||
for (auto *obj : App.get_numbers()) {
|
||||
if (!match.id_equals_entity(obj))
|
||||
auto entity_match = match.match_entity(obj);
|
||||
if (!entity_match.matched)
|
||||
continue;
|
||||
|
||||
if (request->method() == HTTP_GET && match.method_empty()) {
|
||||
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
|
||||
auto detail = get_request_detail(request);
|
||||
std::string data = this->number_json_(obj, obj->state, detail);
|
||||
request->send(200, "application/json", data.c_str());
|
||||
@@ -999,9 +1128,10 @@ void WebServer::on_date_update(datetime::DateEntity *obj) {
|
||||
}
|
||||
void WebServer::handle_date_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||
for (auto *obj : App.get_dates()) {
|
||||
if (!match.id_equals_entity(obj))
|
||||
auto entity_match = match.match_entity(obj);
|
||||
if (!entity_match.matched)
|
||||
continue;
|
||||
if (request->method() == HTTP_GET && match.method_empty()) {
|
||||
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
|
||||
auto detail = get_request_detail(request);
|
||||
std::string data = this->date_json_(obj, detail);
|
||||
request->send(200, "application/json", data.c_str());
|
||||
@@ -1062,9 +1192,10 @@ void WebServer::on_time_update(datetime::TimeEntity *obj) {
|
||||
}
|
||||
void WebServer::handle_time_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||
for (auto *obj : App.get_times()) {
|
||||
if (!match.id_equals_entity(obj))
|
||||
auto entity_match = match.match_entity(obj);
|
||||
if (!entity_match.matched)
|
||||
continue;
|
||||
if (request->method() == HTTP_GET && match.method_empty()) {
|
||||
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
|
||||
auto detail = get_request_detail(request);
|
||||
std::string data = this->time_json_(obj, detail);
|
||||
request->send(200, "application/json", data.c_str());
|
||||
@@ -1124,9 +1255,10 @@ void WebServer::on_datetime_update(datetime::DateTimeEntity *obj) {
|
||||
}
|
||||
void WebServer::handle_datetime_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||
for (auto *obj : App.get_datetimes()) {
|
||||
if (!match.id_equals_entity(obj))
|
||||
auto entity_match = match.match_entity(obj);
|
||||
if (!entity_match.matched)
|
||||
continue;
|
||||
if (request->method() == HTTP_GET && match.method_empty()) {
|
||||
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
|
||||
auto detail = get_request_detail(request);
|
||||
std::string data = this->datetime_json_(obj, detail);
|
||||
request->send(200, "application/json", data.c_str());
|
||||
@@ -1188,10 +1320,11 @@ void WebServer::on_text_update(text::Text *obj) {
|
||||
}
|
||||
void WebServer::handle_text_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||
for (auto *obj : App.get_texts()) {
|
||||
if (!match.id_equals_entity(obj))
|
||||
auto entity_match = match.match_entity(obj);
|
||||
if (!entity_match.matched)
|
||||
continue;
|
||||
|
||||
if (request->method() == HTTP_GET && match.method_empty()) {
|
||||
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
|
||||
auto detail = get_request_detail(request);
|
||||
std::string data = this->text_json_(obj, obj->state, detail);
|
||||
request->send(200, "application/json", data.c_str());
|
||||
@@ -1244,10 +1377,11 @@ void WebServer::on_select_update(select::Select *obj) {
|
||||
}
|
||||
void WebServer::handle_select_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||
for (auto *obj : App.get_selects()) {
|
||||
if (!match.id_equals_entity(obj))
|
||||
auto entity_match = match.match_entity(obj);
|
||||
if (!entity_match.matched)
|
||||
continue;
|
||||
|
||||
if (request->method() == HTTP_GET && match.method_empty()) {
|
||||
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
|
||||
auto detail = get_request_detail(request);
|
||||
std::string data = this->select_json_(obj, obj->has_state() ? obj->current_option() : "", detail);
|
||||
request->send(200, "application/json", data.c_str());
|
||||
@@ -1301,10 +1435,11 @@ void WebServer::on_climate_update(climate::Climate *obj) {
|
||||
}
|
||||
void WebServer::handle_climate_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||
for (auto *obj : App.get_climates()) {
|
||||
if (!match.id_equals_entity(obj))
|
||||
auto entity_match = match.match_entity(obj);
|
||||
if (!entity_match.matched)
|
||||
continue;
|
||||
|
||||
if (request->method() == HTTP_GET && match.method_empty()) {
|
||||
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
|
||||
auto detail = get_request_detail(request);
|
||||
std::string data = this->climate_json_(obj, detail);
|
||||
request->send(200, "application/json", data.c_str());
|
||||
@@ -1451,10 +1586,11 @@ void WebServer::on_lock_update(lock::Lock *obj) {
|
||||
}
|
||||
void WebServer::handle_lock_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||
for (lock::Lock *obj : App.get_locks()) {
|
||||
if (!match.id_equals_entity(obj))
|
||||
auto entity_match = match.match_entity(obj);
|
||||
if (!entity_match.matched)
|
||||
continue;
|
||||
|
||||
if (request->method() == HTTP_GET && match.method_empty()) {
|
||||
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
|
||||
auto detail = get_request_detail(request);
|
||||
std::string data = this->lock_json_(obj, obj->state, detail);
|
||||
request->send(200, "application/json", data.c_str());
|
||||
@@ -1525,10 +1661,11 @@ void WebServer::on_valve_update(valve::Valve *obj) {
|
||||
}
|
||||
void WebServer::handle_valve_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||
for (valve::Valve *obj : App.get_valves()) {
|
||||
if (!match.id_equals_entity(obj))
|
||||
auto entity_match = match.match_entity(obj);
|
||||
if (!entity_match.matched)
|
||||
continue;
|
||||
|
||||
if (request->method() == HTTP_GET && match.method_empty()) {
|
||||
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
|
||||
auto detail = get_request_detail(request);
|
||||
std::string data = this->valve_json_(obj, detail);
|
||||
request->send(200, "application/json", data.c_str());
|
||||
@@ -1609,10 +1746,11 @@ void WebServer::on_alarm_control_panel_update(alarm_control_panel::AlarmControlP
|
||||
}
|
||||
void WebServer::handle_alarm_control_panel_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||
for (alarm_control_panel::AlarmControlPanel *obj : App.get_alarm_control_panels()) {
|
||||
if (!match.id_equals_entity(obj))
|
||||
auto entity_match = match.match_entity(obj);
|
||||
if (!entity_match.matched)
|
||||
continue;
|
||||
|
||||
if (request->method() == HTTP_GET && match.method_empty()) {
|
||||
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
|
||||
auto detail = get_request_detail(request);
|
||||
std::string data = this->alarm_control_panel_json_(obj, obj->get_state(), detail);
|
||||
request->send(200, "application/json", data.c_str());
|
||||
@@ -1690,11 +1828,12 @@ void WebServer::on_event(event::Event *obj) {
|
||||
|
||||
void WebServer::handle_event_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||
for (event::Event *obj : App.get_events()) {
|
||||
if (!match.id_equals_entity(obj))
|
||||
auto entity_match = match.match_entity(obj);
|
||||
if (!entity_match.matched)
|
||||
continue;
|
||||
|
||||
// Note: request->method() is always HTTP_GET here (canHandle ensures this)
|
||||
if (match.method_empty()) {
|
||||
if (entity_match.action_is_empty) {
|
||||
auto detail = get_request_detail(request);
|
||||
std::string data = this->event_json_(obj, "", detail);
|
||||
request->send(200, "application/json", data.c_str());
|
||||
@@ -1759,10 +1898,11 @@ void WebServer::on_update(update::UpdateEntity *obj) {
|
||||
}
|
||||
void WebServer::handle_update_request(AsyncWebServerRequest *request, const UrlMatch &match) {
|
||||
for (update::UpdateEntity *obj : App.get_updates()) {
|
||||
if (!match.id_equals_entity(obj))
|
||||
auto entity_match = match.match_entity(obj);
|
||||
if (!entity_match.matched)
|
||||
continue;
|
||||
|
||||
if (request->method() == HTTP_GET && match.method_empty()) {
|
||||
if (request->method() == HTTP_GET && entity_match.action_is_empty) {
|
||||
auto detail = get_request_detail(request);
|
||||
std::string data = this->update_json_(obj, detail);
|
||||
request->send(200, "application/json", data.c_str());
|
||||
@@ -1973,7 +2113,8 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) {
|
||||
#endif
|
||||
|
||||
// Parse URL for component routing
|
||||
UrlMatch match = match_url(url.c_str(), url.length(), false);
|
||||
// Pass HTTP method to disambiguate 3-segment URLs (GET=sub-device state, POST=main device action)
|
||||
UrlMatch match = match_url(url.c_str(), url.length(), false, request->method() == HTTP_POST);
|
||||
|
||||
// Route to appropriate handler based on domain
|
||||
// NOLINTNEXTLINE(readability-simplify-boolean-expr)
|
||||
|
||||
@@ -35,33 +35,29 @@ extern const size_t ESPHOME_WEBSERVER_JS_INCLUDE_SIZE;
|
||||
|
||||
namespace esphome::web_server {
|
||||
|
||||
/// Result of matching a URL against an entity
|
||||
struct EntityMatchResult {
|
||||
bool matched; ///< True if entity matched the URL
|
||||
bool action_is_empty; ///< True if no action/method segment in URL
|
||||
};
|
||||
|
||||
/// Internal helper struct that is used to parse incoming URLs
|
||||
struct UrlMatch {
|
||||
const char *domain; ///< Pointer to domain within URL, for example "sensor"
|
||||
const char *id; ///< Pointer to id within URL, for example "living_room_fan"
|
||||
const char *method; ///< Pointer to method within URL, for example "turn_on"
|
||||
uint8_t domain_len; ///< Length of domain string
|
||||
uint8_t id_len; ///< Length of id string
|
||||
uint8_t method_len; ///< Length of method string
|
||||
bool valid; ///< Whether this match is valid
|
||||
StringRef domain; ///< Domain within URL, for example "sensor"
|
||||
StringRef id; ///< Entity name/id within URL, for example "Temperature"
|
||||
StringRef method; ///< Method within URL, for example "turn_on"
|
||||
#ifdef USE_DEVICES
|
||||
StringRef device_name; ///< Device name within URL, empty for main device
|
||||
#endif
|
||||
bool valid{false}; ///< Whether this match is valid
|
||||
|
||||
// Helper methods for string comparisons
|
||||
bool domain_equals(const char *str) const {
|
||||
return domain && domain_len == strlen(str) && memcmp(domain, str, domain_len) == 0;
|
||||
}
|
||||
bool domain_equals(const char *str) const { return this->domain == str; }
|
||||
bool method_equals(const char *str) const { return this->method == str; }
|
||||
|
||||
bool id_equals_entity(EntityBase *entity) const {
|
||||
// Get object_id with zero heap allocation
|
||||
char object_id_buf[OBJECT_ID_MAX_LEN];
|
||||
StringRef object_id = entity->get_object_id_to(object_id_buf);
|
||||
return id && id_len == object_id.size() && memcmp(id, object_id.c_str(), id_len) == 0;
|
||||
}
|
||||
|
||||
bool method_equals(const char *str) const {
|
||||
return method && method_len == strlen(str) && memcmp(method, str, method_len) == 0;
|
||||
}
|
||||
|
||||
bool method_empty() const { return method_len == 0; }
|
||||
/// Match entity by name first, then fall back to object_id with deprecation warning
|
||||
/// Returns EntityMatchResult with match status and whether action segment is empty
|
||||
EntityMatchResult match_entity(EntityBase *entity) const;
|
||||
};
|
||||
|
||||
#ifdef USE_WEBSERVER_SORTING
|
||||
|
||||
@@ -5,6 +5,29 @@
|
||||
|
||||
namespace esphome::web_server {
|
||||
|
||||
// Write HTML-escaped text to stream (escapes ", &, <, >)
|
||||
static void write_html_escaped(AsyncResponseStream *stream, const char *text) {
|
||||
for (const char *p = text; *p; ++p) {
|
||||
switch (*p) {
|
||||
case '"':
|
||||
stream->print(""");
|
||||
break;
|
||||
case '&':
|
||||
stream->print("&");
|
||||
break;
|
||||
case '<':
|
||||
stream->print("<");
|
||||
break;
|
||||
case '>':
|
||||
stream->print(">");
|
||||
break;
|
||||
default:
|
||||
stream->write(*p);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void write_row(AsyncResponseStream *stream, EntityBase *obj, const std::string &klass, const std::string &action,
|
||||
const std::function<void(AsyncResponseStream &stream, EntityBase *obj)> &action_func = nullptr) {
|
||||
stream->print("<tr class=\"");
|
||||
@@ -16,8 +39,27 @@ void write_row(AsyncResponseStream *stream, EntityBase *obj, const std::string &
|
||||
stream->print("-");
|
||||
char object_id_buf[OBJECT_ID_MAX_LEN];
|
||||
stream->print(obj->get_object_id_to(object_id_buf).c_str());
|
||||
// Add data attributes for hierarchical URL support
|
||||
stream->print("\" data-domain=\"");
|
||||
stream->print(klass.c_str());
|
||||
stream->print("\" data-name=\"");
|
||||
write_html_escaped(stream, obj->get_name().c_str());
|
||||
#ifdef USE_DEVICES
|
||||
Device *device = obj->get_device();
|
||||
if (device != nullptr) {
|
||||
stream->print("\" data-device=\"");
|
||||
write_html_escaped(stream, device->get_name());
|
||||
}
|
||||
#endif
|
||||
stream->print("\"><td>");
|
||||
stream->print(obj->get_name().c_str());
|
||||
#ifdef USE_DEVICES
|
||||
if (device != nullptr) {
|
||||
stream->print("[");
|
||||
write_html_escaped(stream, device->get_name());
|
||||
stream->print("] ");
|
||||
}
|
||||
#endif
|
||||
write_html_escaped(stream, obj->get_name().c_str());
|
||||
stream->print("</td><td></td><td>");
|
||||
stream->print(action.c_str());
|
||||
if (action_func) {
|
||||
|
||||
@@ -13,7 +13,8 @@ namespace web_server_idf {
|
||||
|
||||
static const char *const TAG = "web_server_idf_utils";
|
||||
|
||||
void url_decode(char *str) {
|
||||
size_t url_decode(char *str) {
|
||||
char *start = str;
|
||||
char *ptr = str, buf;
|
||||
for (; *str; str++, ptr++) {
|
||||
if (*str == '%') {
|
||||
@@ -31,7 +32,8 @@ void url_decode(char *str) {
|
||||
*ptr = *str;
|
||||
}
|
||||
}
|
||||
*ptr = *str;
|
||||
*ptr = '\0';
|
||||
return ptr - start;
|
||||
}
|
||||
|
||||
bool request_has_header(httpd_req_t *req, const char *name) { return httpd_req_get_hdr_value_len(req, name); }
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
namespace esphome {
|
||||
namespace web_server_idf {
|
||||
|
||||
/// Decode URL-encoded string in-place (e.g., %20 -> space, + -> space)
|
||||
/// Returns the new length of the decoded string
|
||||
size_t url_decode(char *str);
|
||||
|
||||
bool request_has_header(httpd_req_t *req, const char *name);
|
||||
optional<std::string> request_get_header(httpd_req_t *req, const char *name);
|
||||
optional<std::string> request_get_url_query(httpd_req_t *req);
|
||||
|
||||
@@ -247,11 +247,20 @@ optional<std::string> AsyncWebServerRequest::get_header(const char *name) const
|
||||
}
|
||||
|
||||
std::string AsyncWebServerRequest::url() const {
|
||||
auto *str = strchr(this->req_->uri, '?');
|
||||
if (str == nullptr) {
|
||||
return this->req_->uri;
|
||||
auto *query_start = strchr(this->req_->uri, '?');
|
||||
std::string result;
|
||||
if (query_start == nullptr) {
|
||||
result = this->req_->uri;
|
||||
} else {
|
||||
result = std::string(this->req_->uri, query_start - this->req_->uri);
|
||||
}
|
||||
return std::string(this->req_->uri, str - this->req_->uri);
|
||||
// Decode URL-encoded characters in-place (e.g., %20 -> space)
|
||||
// This matches AsyncWebServer behavior on Arduino
|
||||
if (!result.empty()) {
|
||||
size_t new_len = url_decode(&result[0]);
|
||||
result.resize(new_len);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string AsyncWebServerRequest::host() const { return this->get_header("Host").value(); }
|
||||
|
||||
@@ -1981,6 +1981,26 @@ MQTT_COMMAND_COMPONENT_SCHEMA = MQTT_COMPONENT_SCHEMA.extend(
|
||||
)
|
||||
|
||||
|
||||
def _validate_no_slash(value):
|
||||
"""Validate that a name does not contain '/' characters.
|
||||
|
||||
The '/' character is used as a path separator in web server URLs,
|
||||
so it cannot be used in entity or device names.
|
||||
"""
|
||||
if "/" in value:
|
||||
raise Invalid(
|
||||
f"Name cannot contain '/' character (used as URL path separator): {value}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
# Maximum length for entity, device, and area names
|
||||
# This ensures web server URL IDs fit in a 280-byte buffer:
|
||||
# domain(20) + "/" + device(120) + "/" + name(120) + null = 263 bytes
|
||||
# Note: Must be < 255 because web_server UrlMatch uses uint8_t for length fields
|
||||
NAME_MAX_LENGTH = 120
|
||||
|
||||
|
||||
def _validate_entity_name(value):
|
||||
value = string(value)
|
||||
try:
|
||||
@@ -1991,9 +2011,28 @@ def _validate_entity_name(value):
|
||||
requires_friendly_name(
|
||||
"Name cannot be None when esphome->friendly_name is not set!"
|
||||
)(value)
|
||||
if value is not None:
|
||||
# Validate length for web server URL compatibility
|
||||
if len(value) > NAME_MAX_LENGTH:
|
||||
raise Invalid(
|
||||
f"Name is too long ({len(value)} chars). "
|
||||
f"Maximum length is {NAME_MAX_LENGTH} characters."
|
||||
)
|
||||
# Validate no '/' in name for web server URL compatibility
|
||||
_validate_no_slash(value)
|
||||
return value
|
||||
|
||||
|
||||
def string_no_slash(value):
|
||||
"""Validate a string that cannot contain '/' characters.
|
||||
|
||||
Used for device and area names where '/' is reserved as a URL path separator.
|
||||
Use with cv.Length() to also enforce maximum length.
|
||||
"""
|
||||
value = string(value)
|
||||
return _validate_no_slash(value)
|
||||
|
||||
|
||||
ENTITY_BASE_SCHEMA = Schema(
|
||||
{
|
||||
Optional(CONF_NAME): _validate_entity_name,
|
||||
|
||||
@@ -186,14 +186,14 @@ else:
|
||||
AREA_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_ID): cv.declare_id(Area),
|
||||
cv.Required(CONF_NAME): cv.string,
|
||||
cv.Required(CONF_NAME): cv.All(cv.string_no_slash, cv.Length(max=120)),
|
||||
}
|
||||
)
|
||||
|
||||
DEVICE_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(CONF_ID): cv.declare_id(Device),
|
||||
cv.Required(CONF_NAME): cv.string,
|
||||
cv.Required(CONF_NAME): cv.All(cv.string_no_slash, cv.Length(max=120)),
|
||||
cv.Optional(CONF_AREA_ID): cv.use_id(Area),
|
||||
}
|
||||
)
|
||||
@@ -207,7 +207,9 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_NAME): cv.valid_name,
|
||||
cv.Optional(CONF_FRIENDLY_NAME, ""): cv.All(cv.string, cv.Length(max=120)),
|
||||
cv.Optional(CONF_FRIENDLY_NAME, ""): cv.All(
|
||||
cv.string_no_slash, cv.Length(max=120)
|
||||
),
|
||||
cv.Optional(CONF_AREA): validate_area_config,
|
||||
cv.Optional(CONF_COMMENT): cv.All(cv.string, cv.Length(max=255)),
|
||||
cv.Required(CONF_BUILD_PATH): cv.string,
|
||||
|
||||
@@ -100,6 +100,8 @@ class EntityBase {
|
||||
return this->device_->get_device_id();
|
||||
}
|
||||
void set_device(Device *device) { this->device_ = device; }
|
||||
// Get the device this entity belongs to (nullptr if main device)
|
||||
Device *get_device() const { return this->device_; }
|
||||
#endif
|
||||
|
||||
// Check if this entity has state
|
||||
|
||||
@@ -502,3 +502,60 @@ def test_only_with_user_value_overrides_default() -> None:
|
||||
|
||||
result = schema({"mqtt_id": "custom_id"})
|
||||
assert result.get("mqtt_id") == "custom_id"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ("hello", "Hello World", "test_name", "温度"))
|
||||
def test_string_no_slash__valid(value: str) -> None:
|
||||
actual = config_validation.string_no_slash(value)
|
||||
assert actual == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ("has/slash", "a/b/c", "/leading", "trailing/"))
|
||||
def test_string_no_slash__slash_rejected(value: str) -> None:
|
||||
with pytest.raises(Invalid, match="cannot contain '/' character"):
|
||||
config_validation.string_no_slash(value)
|
||||
|
||||
|
||||
def test_string_no_slash__long_string_allowed() -> None:
|
||||
# string_no_slash doesn't enforce length - use cv.Length() separately
|
||||
long_value = "x" * 200
|
||||
assert config_validation.string_no_slash(long_value) == long_value
|
||||
|
||||
|
||||
def test_string_no_slash__empty() -> None:
|
||||
assert config_validation.string_no_slash("") == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ("Temperature", "Living Room Light", "温度传感器"))
|
||||
def test_validate_entity_name__valid(value: str) -> None:
|
||||
actual = config_validation._validate_entity_name(value)
|
||||
assert actual == value
|
||||
|
||||
|
||||
def test_validate_entity_name__slash_rejected() -> None:
|
||||
with pytest.raises(Invalid, match="cannot contain '/' character"):
|
||||
config_validation._validate_entity_name("has/slash")
|
||||
|
||||
|
||||
def test_validate_entity_name__max_length() -> None:
|
||||
# 120 chars should pass
|
||||
assert config_validation._validate_entity_name("x" * 120) == "x" * 120
|
||||
|
||||
# 121 chars should fail
|
||||
with pytest.raises(Invalid, match="too long.*121 chars.*Maximum.*120"):
|
||||
config_validation._validate_entity_name("x" * 121)
|
||||
|
||||
|
||||
def test_validate_entity_name__none_without_friendly_name() -> None:
|
||||
# When name is "None" and friendly_name is not set, it should fail
|
||||
CORE.friendly_name = None
|
||||
with pytest.raises(Invalid, match="friendly_name is not set"):
|
||||
config_validation._validate_entity_name("None")
|
||||
|
||||
|
||||
def test_validate_entity_name__none_with_friendly_name() -> None:
|
||||
# When name is "None" but friendly_name is set, it should return None
|
||||
CORE.friendly_name = "My Device"
|
||||
result = config_validation._validate_entity_name("None")
|
||||
assert result is None
|
||||
CORE.friendly_name = None # Reset
|
||||
|
||||
Reference in New Issue
Block a user