mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2026-06-15 12:26:33 +03:00
remove HTTPClient
This commit is contained in:
@@ -1,24 +0,0 @@
|
|||||||
#ifndef HTTPClient_H_
|
|
||||||
#define HTTPClient_H_
|
|
||||||
|
|
||||||
#include "WString.h"
|
|
||||||
|
|
||||||
class HTTPClient {
|
|
||||||
public:
|
|
||||||
bool begin(String url) {
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
void end(void) {};
|
|
||||||
int GET() {
|
|
||||||
return 200;
|
|
||||||
};
|
|
||||||
int POST(String payload) {
|
|
||||||
return 200;
|
|
||||||
};
|
|
||||||
void addHeader(const String & name, const String & value, bool first = false, bool replace = true) {};
|
|
||||||
String getString(void) {
|
|
||||||
return "Hello, World!";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
#endif /* HTTPClient_H_ */
|
|
||||||
@@ -166,6 +166,7 @@ bool MqttSettingsService::configureMqtt() {
|
|||||||
if (_state.rootCA == "insecure") {
|
if (_state.rootCA == "insecure") {
|
||||||
#if defined(EMSESP_DEBUG)
|
#if defined(EMSESP_DEBUG)
|
||||||
emsesp::EMSESP::logger().debug("Start insecure MQTT");
|
emsesp::EMSESP::logger().debug("Start insecure MQTT");
|
||||||
|
#endif
|
||||||
static_cast<espMqttClientSecure *>(_mqttClient)->setInsecure();
|
static_cast<espMqttClientSecure *>(_mqttClient)->setInsecure();
|
||||||
} else {
|
} else {
|
||||||
#if defined(EMSESP_DEBUG)
|
#if defined(EMSESP_DEBUG)
|
||||||
@@ -185,7 +186,6 @@ bool MqttSettingsService::configureMqtt() {
|
|||||||
static_cast<espMqttClientSecure *>(_mqttClient)->setWill(will_topic, 1, true, "offline"); // QOS 1, retain
|
static_cast<espMqttClientSecure *>(_mqttClient)->setWill(will_topic, 1, true, "offline"); // QOS 1, retain
|
||||||
return _mqttClient->connect();
|
return _mqttClient->connect();
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
static_cast<espMqttClient *>(_mqttClient)->setServer(_state.host.c_str(), _state.port);
|
static_cast<espMqttClient *>(_mqttClient)->setServer(_state.host.c_str(), _state.port);
|
||||||
if (_state.username.length() > 0) {
|
if (_state.username.length() > 0) {
|
||||||
static_cast<espMqttClient *>(_mqttClient)->setCredentials(_state.username.c_str(), _state.password.length() > 0 ? _state.password.c_str() : nullptr);
|
static_cast<espMqttClient *>(_mqttClient)->setCredentials(_state.username.c_str(), _state.password.length() > 0 ? _state.password.c_str() : nullptr);
|
||||||
|
|||||||
@@ -687,15 +687,21 @@ std::string calculate(const std::string & expr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// perform an HTTP/HTTPS request; returns the HTTP status code (0 on failure or unsupported scheme)
|
// perform an HTTP/HTTPS request; returns the HTTP status code (0 on failure or unsupported scheme)
|
||||||
// for HTTPS the response headers are stripped, so `result` always contains only the body
|
// the response headers are always stripped, so `result` contains only the body
|
||||||
|
// uses ESP_SSLClient for both schemes (SSL is disabled for plain HTTP), avoiding the HTTPClient dependency
|
||||||
int http_request(std::string url, const std::string & method, const std::string & value, JsonObjectConst headers, std::string & result) {
|
int http_request(std::string url, const std::string & method, const std::string & value, JsonObjectConst headers, std::string & result) {
|
||||||
int httpResult = 0;
|
int httpResult = 0;
|
||||||
const bool is_post = value.length() || Helpers::toLower(method) == "post";
|
const bool is_post = value.length() || Helpers::toLower(method) == "post";
|
||||||
const auto lower_url = Helpers::toLower(url.c_str());
|
const auto lower_url = Helpers::toLower(url.c_str());
|
||||||
|
|
||||||
if (lower_url.starts_with("https://")) {
|
const bool is_https = lower_url.starts_with("https://");
|
||||||
|
if (!is_https && !lower_url.starts_with("http://")) {
|
||||||
|
return 0; // unsupported scheme
|
||||||
|
}
|
||||||
|
|
||||||
WiFiClient * basic_client = new WiFiClient;
|
WiFiClient * basic_client = new WiFiClient;
|
||||||
ESP_SSLClient * ssl_client = new ESP_SSLClient;
|
ESP_SSLClient * ssl_client = new ESP_SSLClient;
|
||||||
|
if (is_https) {
|
||||||
ssl_client->setInsecure(); // with root CA we should set here: ssl_client->setCACert(rootCACert);
|
ssl_client->setInsecure(); // with root CA we should set here: ssl_client->setCACert(rootCACert);
|
||||||
// NOTE: 1 KB RX buffer is fine for small JSON-style endpoints used by the scheduler/shunting-yard,
|
// NOTE: 1 KB RX buffer is fine for small JSON-style endpoints used by the scheduler/shunting-yard,
|
||||||
// but it is NOT enough for servers that send full-size TLS records (>1 KB), e.g. GitHub release
|
// but it is NOT enough for servers that send full-size TLS records (>1 KB), e.g. GitHub release
|
||||||
@@ -704,15 +710,21 @@ int http_request(std::string url, const std::string & method, const std::string
|
|||||||
// payloads, bump the RX buffer to 16384 (see uploadFirmwareURL in core/system.cpp for reference).
|
// payloads, bump the RX buffer to 16384 (see uploadFirmwareURL in core/system.cpp for reference).
|
||||||
ssl_client->setBufferSizes(1024, 1024);
|
ssl_client->setBufferSizes(1024, 1024);
|
||||||
ssl_client->setSessionTimeout(120); // Set the timeout in seconds (>=120 seconds)
|
ssl_client->setSessionTimeout(120); // Set the timeout in seconds (>=120 seconds)
|
||||||
url.replace(0, 8, "");
|
}
|
||||||
|
ssl_client->setClient(basic_client, is_https); // enableSSL = false for plain HTTP
|
||||||
|
|
||||||
|
url.replace(0, is_https ? 8 : 7, "");
|
||||||
std::string host = url;
|
std::string host = url;
|
||||||
auto index = url.find_first_of('/');
|
auto index = url.find_first_of('/');
|
||||||
if (index != std::string::npos) {
|
if (index != std::string::npos) {
|
||||||
host = url.substr(0, index);
|
host = url.substr(0, index);
|
||||||
url.replace(0, index, "");
|
url.replace(0, index, "");
|
||||||
|
} else {
|
||||||
|
url = "/";
|
||||||
}
|
}
|
||||||
ssl_client->setClient(basic_client);
|
|
||||||
if (ssl_client->connect(host.c_str(), 443)) {
|
const uint16_t port = is_https ? 443 : 80;
|
||||||
|
if (ssl_client->connect(host.c_str(), port)) {
|
||||||
bool content_set = false;
|
bool content_set = false;
|
||||||
ssl_client->print(is_post ? "POST " : "GET ");
|
ssl_client->print(is_post ? "POST " : "GET ");
|
||||||
ssl_client->print(url.c_str());
|
ssl_client->print(url.c_str());
|
||||||
@@ -755,33 +767,10 @@ int http_request(std::string url, const std::string & method, const std::string
|
|||||||
result.replace(0, index + 4, "");
|
result.replace(0, index + 4, "");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
EMSESP::logger().warning("HTTPS connection failed");
|
EMSESP::logger().warning("%s connection failed", is_https ? "HTTPS" : "HTTP");
|
||||||
}
|
}
|
||||||
delete ssl_client;
|
delete ssl_client;
|
||||||
delete basic_client;
|
delete basic_client;
|
||||||
} else if (lower_url.starts_with("http://")) {
|
|
||||||
HTTPClient * http = new HTTPClient;
|
|
||||||
if (http->begin(url.c_str())) {
|
|
||||||
bool content_set = false;
|
|
||||||
for (JsonPairConst p : headers) {
|
|
||||||
http->addHeader(p.key().c_str(), p.value().as<std::string>().c_str());
|
|
||||||
content_set |= (Helpers::toLower(p.key().c_str()) == "content-type");
|
|
||||||
}
|
|
||||||
if (is_post) {
|
|
||||||
if (!content_set) {
|
|
||||||
http->addHeader(asyncsrv::T_Content_Type, value.starts_with('{') ? asyncsrv::T_application_json : asyncsrv::T_text_plain);
|
|
||||||
}
|
|
||||||
httpResult = http->POST(value.c_str());
|
|
||||||
} else {
|
|
||||||
httpResult = http->GET();
|
|
||||||
}
|
|
||||||
if (httpResult > 0) {
|
|
||||||
result = http->getString().c_str();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
http->end();
|
|
||||||
delete http;
|
|
||||||
}
|
|
||||||
|
|
||||||
return httpResult;
|
return httpResult;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@
|
|||||||
#ifndef EMSESP_SHUNTING_YARD_H_
|
#ifndef EMSESP_SHUNTING_YARD_H_
|
||||||
#define EMSESP_SHUNTING_YARD_H_
|
#define EMSESP_SHUNTING_YARD_H_
|
||||||
|
|
||||||
#include <HTTPClient.h>
|
|
||||||
#include <ArduinoJson.h>
|
#include <ArduinoJson.h>
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
#include <nvs.h>
|
#include <nvs.h>
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#include <HTTPClient.h>
|
|
||||||
#include <map>
|
#include <map>
|
||||||
|
|
||||||
#include "firmwareVersion.h"
|
#include "firmwareVersion.h"
|
||||||
@@ -2957,12 +2956,14 @@ bool System::uploadFirmwareURL(const char * url) {
|
|||||||
|
|
||||||
Shell::loop_all(); // flush log buffers so latest messages are shown in console
|
Shell::loop_all(); // flush log buffers so latest messages are shown in console
|
||||||
|
|
||||||
// detect scheme (case-insensitive)
|
// detect scheme (case-insensitive). Everything below uses the same code path
|
||||||
|
// for HTTP and HTTPS - ESP_SSLClient is configured as a plain TCP passthrough
|
||||||
|
// when SSL is disabled, so we don't need HTTPClient at all.
|
||||||
String scheme = saved_url.substring(0, 8);
|
String scheme = saved_url.substring(0, 8);
|
||||||
scheme.toLowerCase();
|
scheme.toLowerCase();
|
||||||
const bool is_https = scheme.startsWith("https://");
|
const bool is_https = scheme.startsWith("https://");
|
||||||
|
const int scheme_len = is_https ? 8 : 7; // "https://" vs "http://"
|
||||||
|
|
||||||
HTTPClient http;
|
|
||||||
WiFiClient basic_client;
|
WiFiClient basic_client;
|
||||||
ESP_SSLClient ssl_client;
|
ESP_SSLClient ssl_client;
|
||||||
|
|
||||||
@@ -2981,11 +2982,13 @@ bool System::uploadFirmwareURL(const char * url) {
|
|||||||
// record the CDN actually sends in practice.
|
// record the CDN actually sends in practice.
|
||||||
ssl_client.setBufferSizes(16384, 1024);
|
ssl_client.setBufferSizes(16384, 1024);
|
||||||
ssl_client.setSessionTimeout(120);
|
ssl_client.setSessionTimeout(120);
|
||||||
|
}
|
||||||
basic_client.setTimeout(15000); // socket-level read timeout
|
basic_client.setTimeout(15000); // socket-level read timeout
|
||||||
ssl_client.setTimeout(15000); // Stream::readBytes timeout used by Update
|
ssl_client.setTimeout(15000); // Stream::readBytes timeout used by Update
|
||||||
ssl_client.setClient(&basic_client);
|
ssl_client.setClient(&basic_client, is_https); // enableSSL = false for plain HTTP
|
||||||
|
|
||||||
String url_remain = saved_url.substring(8); // strip "https://"
|
const uint16_t port = is_https ? 443 : 80;
|
||||||
|
String url_remain = saved_url.substring(scheme_len);
|
||||||
int redirect_count = 0;
|
int redirect_count = 0;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -3002,8 +3005,8 @@ bool System::uploadFirmwareURL(const char * url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
LOG_DEBUG("Connecting to %s", host.c_str());
|
LOG_DEBUG("Connecting to %s", host.c_str());
|
||||||
if (!ssl_client.connect(host.c_str(), 443)) {
|
if (!ssl_client.connect(host.c_str(), port)) {
|
||||||
LOG_ERROR("Firmware upload failed - HTTPS connection failed");
|
LOG_ERROR("Firmware upload failed - connection failed");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3061,19 +3064,26 @@ bool System::uploadFirmwareURL(const char * url) {
|
|||||||
}
|
}
|
||||||
String lower_loc = location;
|
String lower_loc = location;
|
||||||
lower_loc.toLowerCase();
|
lower_loc.toLowerCase();
|
||||||
if (lower_loc.startsWith("https://")) {
|
if (lower_loc.startsWith("https://") || lower_loc.startsWith("http://")) {
|
||||||
url_remain = location.substring(8);
|
// scheme-changing redirect is not supported - the SSL state is
|
||||||
|
// baked in at setClient() time and we don't want to re-init mid-flight
|
||||||
|
const bool new_is_https = lower_loc.startsWith("https://");
|
||||||
|
if (new_is_https != is_https) {
|
||||||
|
LOG_ERROR("Firmware upload failed - cross-scheme redirect to %s", location.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
url_remain = location.substring(new_is_https ? 8 : 7);
|
||||||
} else if (location.startsWith("/")) {
|
} else if (location.startsWith("/")) {
|
||||||
url_remain = host + location; // relative redirect, same host
|
url_remain = host + location; // relative redirect, same host
|
||||||
} else {
|
} else {
|
||||||
LOG_ERROR("Firmware upload failed - non-HTTPS redirect to %s", location.c_str());
|
LOG_ERROR("Firmware upload failed - unsupported redirect to %s", location.c_str());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
LOG_DEBUG("Following redirect to %s", url_remain.c_str());
|
LOG_DEBUG("Following redirect to %s", url_remain.c_str());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (http_code != HTTP_CODE_OK) {
|
if (http_code != 200) {
|
||||||
ssl_client.stop();
|
ssl_client.stop();
|
||||||
LOG_ERROR("Firmware upload failed - HTTP code %d", http_code);
|
LOG_ERROR("Firmware upload failed - HTTP code %d", http_code);
|
||||||
return false;
|
return false;
|
||||||
@@ -3085,7 +3095,7 @@ bool System::uploadFirmwareURL(const char * url) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// wait for the first byte of the body so Update.writeStream's peek() sees real data
|
// wait for the first byte of the body so the read loop sees real data
|
||||||
// (headers and body may arrive in separate TLS records)
|
// (headers and body may arrive in separate TLS records)
|
||||||
uint32_t body_wait = millis();
|
uint32_t body_wait = millis();
|
||||||
while (ssl_client.connected() && !ssl_client.available() && millis() - body_wait < 8000) {
|
while (ssl_client.connected() && !ssl_client.available() && millis() - body_wait < 8000) {
|
||||||
@@ -3101,23 +3111,6 @@ bool System::uploadFirmwareURL(const char * url) {
|
|||||||
firmware_size = content_length;
|
firmware_size = content_length;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// HTTP path
|
|
||||||
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); // important for GitHub 302's
|
|
||||||
http.setTimeout(8000);
|
|
||||||
http.useHTTP10(true); // use HTTP/1.0 for update since the update handler does not support any transfer Encoding
|
|
||||||
http.begin(saved_url);
|
|
||||||
|
|
||||||
int httpCode = http.GET();
|
|
||||||
if (httpCode != HTTP_CODE_OK) {
|
|
||||||
LOG_ERROR("Firmware upload failed - HTTP code %d", httpCode);
|
|
||||||
http.end();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
firmware_size = http.getSize();
|
|
||||||
stream = http.getStreamPtr();
|
|
||||||
}
|
|
||||||
|
|
||||||
// check we have a valid size
|
// check we have a valid size
|
||||||
if (firmware_size < 1677721) { // 1.6MB or greater is required
|
if (firmware_size < 1677721) { // 1.6MB or greater is required
|
||||||
@@ -3150,8 +3143,7 @@ bool System::uploadFirmwareURL(const char * url) {
|
|||||||
// wait for some data or for the connection to drop
|
// wait for some data or for the connection to drop
|
||||||
uint32_t wait_start = millis();
|
uint32_t wait_start = millis();
|
||||||
while (!stream->available()) {
|
while (!stream->available()) {
|
||||||
const bool still_connected = is_https ? ssl_client.connected() : http.connected();
|
if (!ssl_client.connected()) {
|
||||||
if (!still_connected) {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (millis() - wait_start > READ_TIMEOUT_MS) {
|
if (millis() - wait_start > READ_TIMEOUT_MS) {
|
||||||
|
|||||||
@@ -20,7 +20,8 @@
|
|||||||
|
|
||||||
#ifndef EMSESP_STANDALONE
|
#ifndef EMSESP_STANDALONE
|
||||||
#include <esp_ota_ops.h>
|
#include <esp_ota_ops.h>
|
||||||
#include <HTTPClient.h>
|
#include <WiFiClient.h>
|
||||||
|
#include <ESP_SSLClient.h>
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
namespace emsesp {
|
namespace emsesp {
|
||||||
@@ -414,30 +415,91 @@ bool WebStatusService::refresh_versions_cache() {
|
|||||||
#ifdef EMSESP_STANDALONE
|
#ifdef EMSESP_STANDALONE
|
||||||
return false;
|
return false;
|
||||||
#else
|
#else
|
||||||
HTTPClient http;
|
// detect scheme from EMSESP_VERSIONS_URL (case-insensitive). One code path for HTTP and HTTPS,
|
||||||
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
|
// using ESP_SSLClient as a plain TCP passthrough when SSL is disabled.
|
||||||
http.setTimeout(5000);
|
String url = EMSESP_VERSIONS_URL;
|
||||||
http.useHTTP10(true);
|
String lower = url;
|
||||||
|
lower.toLowerCase();
|
||||||
if (!http.begin(EMSESP_VERSIONS_URL)) {
|
const bool is_https = lower.startsWith("https://");
|
||||||
|
if (!is_https && !lower.startsWith("http://")) {
|
||||||
#if defined(EMSESP_DEBUG)
|
#if defined(EMSESP_DEBUG)
|
||||||
EMSESP::logger().debug("versions.json: failed to start HTTPS request");
|
EMSESP::logger().debug("versions.json: unsupported scheme");
|
||||||
|
#endif
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const int scheme_len = is_https ? 8 : 7;
|
||||||
|
|
||||||
|
WiFiClient basic_client;
|
||||||
|
ESP_SSLClient ssl_client;
|
||||||
|
if (is_https) {
|
||||||
|
ssl_client.setInsecure();
|
||||||
|
ssl_client.setBufferSizes(16384, 1024); // versions.json fits easily but TLS records may be full-size
|
||||||
|
ssl_client.setSessionTimeout(120);
|
||||||
|
}
|
||||||
|
basic_client.setTimeout(5000);
|
||||||
|
ssl_client.setTimeout(5000);
|
||||||
|
ssl_client.setClient(&basic_client, is_https);
|
||||||
|
|
||||||
|
// split into host and path
|
||||||
|
String rest = url.substring(scheme_len);
|
||||||
|
String host;
|
||||||
|
String path;
|
||||||
|
int s = rest.indexOf('/');
|
||||||
|
if (s < 0) {
|
||||||
|
host = rest;
|
||||||
|
path = "/";
|
||||||
|
} else {
|
||||||
|
host = rest.substring(0, s);
|
||||||
|
path = rest.substring(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ssl_client.connect(host.c_str(), is_https ? 443 : 80)) {
|
||||||
|
#if defined(EMSESP_DEBUG)
|
||||||
|
EMSESP::logger().debug("versions.json: connection failed");
|
||||||
#endif
|
#endif
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
int httpCode = http.GET();
|
// minimal HTTP/1.0 GET so we don't have to handle chunked encoding
|
||||||
if (httpCode != HTTP_CODE_OK) {
|
ssl_client.print("GET ");
|
||||||
|
ssl_client.print(path);
|
||||||
|
ssl_client.println(" HTTP/1.0");
|
||||||
|
ssl_client.print("Host: ");
|
||||||
|
ssl_client.println(host);
|
||||||
|
ssl_client.println("User-Agent: EMS-ESP");
|
||||||
|
ssl_client.println("Connection: close");
|
||||||
|
ssl_client.print("\r\n");
|
||||||
|
|
||||||
|
// wait for the first byte
|
||||||
|
uint32_t ms = millis();
|
||||||
|
while (ssl_client.connected() && !ssl_client.available() && millis() - ms < 5000) {
|
||||||
|
delay(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse status line
|
||||||
|
String status_line = ssl_client.readStringUntil('\n');
|
||||||
|
int sp = status_line.indexOf(' ');
|
||||||
|
int http_code = (sp >= 0) ? status_line.substring(sp + 1, sp + 4).toInt() : 0;
|
||||||
|
if (http_code != 200) {
|
||||||
|
ssl_client.stop();
|
||||||
#if defined(EMSESP_DEBUG)
|
#if defined(EMSESP_DEBUG)
|
||||||
EMSESP::logger().debug("versions.json: HTTP error code %d", httpCode);
|
EMSESP::logger().debug("versions.json: HTTP error code %d", http_code);
|
||||||
#endif
|
#endif
|
||||||
http.end();
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// skip headers
|
||||||
|
while (ssl_client.connected() || ssl_client.available()) {
|
||||||
|
String line = ssl_client.readStringUntil('\n');
|
||||||
|
line.trim();
|
||||||
|
if (line.isEmpty()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
JsonDocument doc(PSRAM_DOC);
|
JsonDocument doc(PSRAM_DOC);
|
||||||
DeserializationError err = deserializeJson(doc, http.getStream());
|
DeserializationError err = deserializeJson(doc, ssl_client);
|
||||||
http.end();
|
ssl_client.stop();
|
||||||
if (err) {
|
if (err) {
|
||||||
#if defined(EMSESP_DEBUG)
|
#if defined(EMSESP_DEBUG)
|
||||||
EMSESP::logger().debug("versions.json: parse error");
|
EMSESP::logger().debug("versions.json: parse error");
|
||||||
|
|||||||
Reference in New Issue
Block a user