use core::fmt::Write;
use core::ops::{Deref, DerefMut};
use heapless::{String, index_map::FnvIndexMap};
use super::WifiCredentials;
#[repr(transparent)]
pub struct HtmlBuffer(String<16384>);
impl HtmlBuffer {
#[must_use]
pub const fn new() -> Self {
Self(String::new())
}
#[must_use]
pub fn as_bytes(&self) -> &[u8] {
self.0.as_bytes()
}
}
impl Write for HtmlBuffer {
fn write_str(&mut self, text: &str) -> core::fmt::Result {
self.0.write_str(text)
}
}
impl Deref for HtmlBuffer {
type Target = String<16384>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for HtmlBuffer {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
pub trait WifiAutoField {
type Error;
fn render(&self, page: &mut HtmlBuffer) -> Result<(), Self::Error>;
fn parse(&self, form: &FormData<'_>) -> Result<(), Self::Error>;
fn is_satisfied(&self) -> Result<bool, Self::Error> {
Ok(true)
}
}
#[doc(hidden)] pub struct FormData<'a> {
params: &'a FormMap,
}
impl<'a> FormData<'a> {
fn new(params: &'a FormMap) -> Self {
Self { params }
}
#[must_use]
pub fn get(&self, key: &str) -> Option<&str> {
self.params
.iter()
.find(|(stored_key, _)| stored_key.as_str() == key)
.map(|(_, value)| value.as_str())
}
}
type FormKey = String<32>;
type FormValue = String<256>;
type FormMap = FnvIndexMap<FormKey, FormValue, 32>;
#[must_use]
pub fn parse_post<E>(
request: &str,
defaults: Option<&WifiCredentials>,
fields: &[&dyn WifiAutoField<Error = E>],
) -> Option<WifiCredentials> {
let body_start = request.find("\r\n\r\n")? + 4;
let body = &request[body_start..];
let mut params: FormMap = FormMap::new();
let mut ssid = heapless::String::<32>::new();
let mut submitted_password = heapless::String::<64>::new();
let mut keep_saved_password = false;
for param in body.split('&') {
if let Some((key, value)) = param.split_once('=') {
let decoded_key = url_decode::<32>(key);
let decoded_value = url_decode::<256>(value);
params
.insert(decoded_key.clone(), decoded_value.clone())
.ok()?;
match decoded_key.as_str() {
"ssid" => {
ssid.push_str(&decoded_value).ok()?;
}
"password" => {
submitted_password.push_str(&decoded_value).ok()?;
}
"keep_saved_password" => {
keep_saved_password = decoded_value.as_str() == "1";
}
_ => {}
}
}
}
if ssid.is_empty() {
return None;
}
let form_data = FormData::new(¶ms);
for field in fields {
field.parse(&form_data).ok()?;
}
let password = if keep_saved_password {
let defaults_wifi_credentials = defaults?;
if defaults_wifi_credentials.password.is_empty() {
return None;
}
defaults_wifi_credentials.password.clone()
} else {
if submitted_password.is_empty() {
return None;
}
submitted_password
};
Some(WifiCredentials { ssid, password })
}
#[must_use]
pub fn generate_config_page<E>(
defaults: Option<&WifiCredentials>,
fields: &[&dyn WifiAutoField<Error = E>],
) -> HtmlBuffer {
let mut page = HtmlBuffer::new();
let ssid = defaults
.as_ref()
.map(|wifi_credentials| escape_html::<160>(wifi_credentials.ssid.as_str()))
.unwrap_or_else(heapless::String::new);
let has_saved_password = defaults
.as_ref()
.map(|wifi_credentials| !wifi_credentials.password.is_empty())
.unwrap_or(false);
write!(
page,
"HTTP/1.1 200 OK\r\n\
Content-Type: text/html\r\n\
Connection: close\r\n\
\r\n\
<!DOCTYPE html>\
<html>\
<head>\
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
<title>WiFi Configuration</title>\
<link rel=\"icon\" href=\"data:,\">\
<style>\
body {{ font-family: Arial, sans-serif; max-width: 500px; margin: 50px auto; padding: 20px; }}\
h1 {{ color: #333; }}\
form {{ margin-top: 20px; }}\
input, select {{ width: 100%; padding: 10px; margin: 10px 0; box-sizing: border-box; }}\
input.masked {{ color: #777; background-color: #f2f2f2; }}\
label {{ display: block; margin-top: 10px; }}\
.toggle {{ display: flex; align-items: center; gap: 8px; font-size: 0.9rem; color: #444; margin-top: 5px; }}\
.toggle input {{ width: auto; margin: 0; }}\
button {{ width: 100%; padding: 12px; background-color: #2e7d32; color: white; border: none; cursor: pointer; }}\
button:hover {{ background-color: #1b5e20; }}\
</style>\
<script>\
function togglePasswordVisibility() {{\
var keepSavedPassword = document.getElementById('keep_saved_password');\
if (keepSavedPassword && keepSavedPassword.checked) return;\
var input = document.getElementById('password');\
if (!input) return;\
input.type = input.type === 'password' ? 'text' : 'password';\
}}\
function beginPasswordEdit() {{\
var keepSavedPassword = document.getElementById('keep_saved_password');\
if (!keepSavedPassword || !keepSavedPassword.checked) return;\
keepSavedPassword.checked = false;\
syncPasswordEditing();\
}}\
function syncPasswordEditing() {{\
var keepSavedPassword = document.getElementById('keep_saved_password');\
var passwordInput = document.getElementById('password');\
var showPassword = document.getElementById('show_password');\
if (!keepSavedPassword || !passwordInput) return;\
var isKeepingSavedPassword = keepSavedPassword.checked;\
passwordInput.readOnly = isKeepingSavedPassword;\
passwordInput.required = !isKeepingSavedPassword;\
if (isKeepingSavedPassword) {{\
passwordInput.value = '*******';\
passwordInput.type = 'password';\
passwordInput.classList.add('masked');\
if (showPassword) {{\
showPassword.checked = false;\
showPassword.disabled = true;\
}}\
}} else {{\
if (passwordInput.value === '*******') {{\
passwordInput.value = '';\
}}\
passwordInput.classList.remove('masked');\
if (showPassword) {{\
showPassword.disabled = false;\
}}\
}}\
}}\
</script>\
</head>\
<body>\
<h1>WiFi Configuration</h1>\
<p>Enter your WiFi network credentials:</p>\
<form method=\"POST\" action=\"/\">\
<label for=\"ssid\">WiFi Network Name (SSID):</label>\
<input type=\"text\" id=\"ssid\" name=\"ssid\" value=\"{}\" required>\
<label for=\"password\">Password:</label>\
",
ssid
)
.expect("page HTML exceeds capacity");
if has_saved_password {
page.push_str(
"<label class=\"toggle\"><input type=\"checkbox\" id=\"keep_saved_password\" name=\"keep_saved_password\" value=\"1\" checked onclick=\"syncPasswordEditing()\">Use saved password</label>\
<input type=\"password\" id=\"password\" name=\"password\" onfocus=\"beginPasswordEdit()\" onkeydown=\"beginPasswordEdit()\" onclick=\"beginPasswordEdit()\" onpaste=\"beginPasswordEdit()\">\
<label class=\"toggle\"><input type=\"checkbox\" id=\"show_password\" onclick=\"togglePasswordVisibility()\">Show password</label>\
<script>syncPasswordEditing();</script>\
",
)
.expect("page HTML exceeds capacity");
} else {
page.push_str(
"<input type=\"password\" id=\"password\" name=\"password\" required>\
<label class=\"toggle\"><input type=\"checkbox\" id=\"show_password\" onclick=\"togglePasswordVisibility()\">Show password</label>\
",
)
.expect("page HTML exceeds capacity");
}
for field in fields {
field
.render(&mut page)
.unwrap_or_else(|_| panic!("custom field HTML exceeds page capacity"));
}
page.push_str("<button type=\"submit\">Connect</button></form></body></html>")
.expect("page HTML exceeds capacity");
page
}
fn url_decode<const N: usize>(input: &str) -> heapless::String<N> {
let mut output = heapless::String::<N>::new();
let mut chars = input.chars();
while let Some(character) = chars.next() {
if character == '+' {
output.push(' ').expect("decoded URL exceeds capacity");
} else if character == '%' {
if let (Some(high), Some(low)) = (chars.next(), chars.next()) {
if let (Some(high_digit), Some(low_digit)) = (high.to_digit(16), low.to_digit(16)) {
#[allow(clippy::cast_possible_truncation)]
let byte = ((high_digit << 4) | low_digit) as u8;
if let Ok(decoded) = core::str::from_utf8(&[byte]) {
output
.push_str(decoded)
.expect("decoded URL exceeds capacity");
}
}
}
} else {
output
.push(character)
.expect("decoded URL exceeds capacity");
}
}
output
}
fn escape_html<const N: usize>(value: &str) -> heapless::String<N> {
let mut escaped = heapless::String::<N>::new();
for character in value.chars() {
match character {
'&' => escaped
.push_str("&")
.expect("escaped HTML exceeds capacity"),
'<' => escaped
.push_str("<")
.expect("escaped HTML exceeds capacity"),
'>' => escaped
.push_str(">")
.expect("escaped HTML exceeds capacity"),
'"' => escaped
.push_str(""")
.expect("escaped HTML exceeds capacity"),
'\'' => escaped
.push_str("'")
.expect("escaped HTML exceeds capacity"),
_ => escaped
.push(character)
.expect("escaped HTML exceeds capacity"),
}
}
escaped
}