cat_dev/mion/cgis/
mod.rs

1//! CGI's that are available to interact with the MION on.
2//!
3//! These various CGI web pages you can interact with normally on the web.
4
5/// HTTP Basic authorization header.
6///
7/// This gets passed as the header:
8/// `Authorization: Basic bWlvbjovTXVsdGlfSS9PX05ldHdvcmsv`
9///
10/// Given this is http basic auth, you can decode this string as:
11/// `mion:/Multi_I/O_Network/`
12///
13/// Which means the username is: `mion`, and the password is:
14/// `/Multi_I/O_Network/`.
15const AUTHZ_HEADER: &str = "bWlvbjovTXVsdGlfSS9PX05ldHdvcmsv";
16
17mod control;
18mod dump_eeprom;
19mod dump_memory;
20mod setup;
21mod signal_get;
22mod status;
23mod update;
24
25pub use control::*;
26pub use dump_eeprom::*;
27pub use dump_memory::*;
28pub use setup::*;
29pub use signal_get::*;
30pub use status::*;
31pub use update::*;
32
33use crate::{
34	errors::{NetworkError, NetworkParseError},
35	mion::proto::cgis::MionCGIErrors,
36};
37use bytes::Bytes;
38use form_urlencoded::byte_serialize;
39use reqwest::{Body, Client, Method, Response, Version};
40use std::{fmt::Display, ops::Deref, time::Duration};
41use tokio::time::timeout;
42use tracing::{Instrument, error_span, field::valuable, warn};
43
44/// The default timeout for making HTTP requests.
45///
46/// This is a relatively low value by default as most HTTP pages are fairly simple, and quick to respond.
47pub const DEFAULT_HTTP_TIMEOUT: Duration = Duration::from_secs(5);
48
49/// Perform a request that attempts to remove all the logic for the 'simple'
50/// request cases.
51///
52/// Most MION HTTP Requests follow a simple format:
53///
54/// - Use HTTP Version 1.1.
55/// - Authenticate with the common authorization header.
56/// - Content Type is: `application/x-www-form-urlencoded`.
57/// - Only Response Status Code that is successful is 200.
58/// - There is a response body, and it is a UTF-8 String.
59///
60/// If your request meets all these requirements, then congrats! You can use
61/// this method to implement it. Cutting down on the logic you have to
62/// implement by a ton! Otherwise, you'll have to implement bits, and pieces
63/// yourself.
64async fn do_simple_request<BodyTy>(
65	client: &Client,
66	method: Method,
67	url: String,
68	body: Option<BodyTy>,
69	req_timeout: Option<Duration>,
70) -> Result<String, NetworkError>
71where
72	BodyTy: Into<Body>,
73{
74	let span = error_span!(
75		"cat_dev::mion::cgis::do_simple_request",
76		http.method = %method,
77		http.url = %url,
78	);
79
80	async {
81		let mut req = client
82			.request(method, url)
83			.version(Version::HTTP_11)
84			.header("authorization", format!("Basic {AUTHZ_HEADER}"))
85			.header("content-type", "application/x-www-form-urlencoded")
86			.header("user-agent", concat!("cat-dev/", env!("CARGO_PKG_VERSION")));
87		if let Some(body) = body {
88			req = req.body(body);
89		}
90
91		let timeout_time = req_timeout.unwrap_or(DEFAULT_HTTP_TIMEOUT);
92		let response_body = timeout(
93			timeout_time,
94			assert_status_and_read_body(200, req.send().await.map_err(NetworkError::HTTP)?),
95		)
96		.await
97		.map_err(|_| NetworkError::Timeout(timeout_time))??;
98
99		Ok(String::from_utf8(response_body.into()).map_err(NetworkParseError::Utf8Expected)?)
100	}
101	.instrument(span)
102	.await
103}
104
105fn encode_url_parameters(parameters: &[(impl Deref<Target = str>, impl Display)]) -> String {
106	let mut buff = String::new();
107	let mut first = true;
108
109	for (param_key, param_value) in parameters {
110		if !first {
111			buff.push('&');
112		}
113		first = false;
114		buff.extend(byte_serialize(param_key.as_bytes()));
115		buff.push('=');
116		buff.extend(byte_serialize(format!("{param_value}").as_bytes()));
117	}
118
119	buff
120}
121
122/// Assert that a response status code is a specific code, and get the body.
123///
124/// ## Errors
125///
126/// If the status code did not match the expected result.
127async fn assert_status_and_read_body(
128	needed_status: u16,
129	response: Response,
130) -> Result<Bytes, NetworkError> {
131	let status = response.status().as_u16();
132	let body_result = response.bytes().await.map_err(NetworkError::HTTP);
133	if status != needed_status {
134		if let Ok(body) = body_result {
135			return Err(MionCGIErrors::UnexpectedStatusCode(status, body).into());
136		}
137
138		return Err(MionCGIErrors::UnexpectedStatusCodeNoBody(status).into());
139	}
140
141	body_result
142}
143
144/// Parse out a result status from an HTML page.
145///
146/// If your page ends up rendering up results like: `RESULT:OK` when things are
147/// successful, you can use this method to get that result information.
148fn parse_result_from_body(body: &str, operation_name: &str) -> Result<bool, MionCGIErrors> {
149	let start_tag_location = body
150		.find("<body>")
151		.map(|num| num + 6)
152		.or(
153			// Some pages don't have a start body tag, because MION's HTML stack
154			// is absolute trash.
155			body.find("<HTML>").map(|num| num + 6),
156		)
157		.ok_or_else(|| MionCGIErrors::HtmlResponseMissingBody(body.to_owned()))?;
158	let body_without_start_tag = body.split_at(start_tag_location).1;
159	let end_tag_location = body_without_start_tag
160		.find("</body>")
161		.ok_or_else(|| MionCGIErrors::HtmlResponseMissingBody(body.to_owned()))?;
162	let just_inner_body = body_without_start_tag.split_at(end_tag_location).0;
163	let without_newlines_or_extra_tags = just_inner_body
164		.replace('\n', "")
165		.replace("<CENTER>", "")
166		.replace("</CENTER>", "");
167
168	let mut was_successful = false;
169	let mut returned_result_code = "";
170	let mut log_lines = Vec::with_capacity(0);
171	let mut extra_lines = Vec::with_capacity(0);
172	for line in without_newlines_or_extra_tags
173		.split("<br>")
174		.fold(Vec::new(), |mut accum, item| {
175			accum.extend(item.split("<br/>"));
176			accum
177		}) {
178		let trimmed_line = line.trim();
179		if trimmed_line.is_empty() {
180			continue;
181		}
182
183		if let Some(result_code) = trimmed_line.strip_prefix("RESULT:") {
184			returned_result_code = result_code;
185			if result_code == "OK" {
186				was_successful = true;
187			}
188		} else if trimmed_line.starts_with("INFO:")
189			|| trimmed_line.starts_with("ERROR:")
190			|| trimmed_line.starts_with("WARN:")
191		{
192			log_lines.push(trimmed_line);
193		} else {
194			extra_lines.push(trimmed_line);
195		}
196	}
197
198	if !was_successful {
199		warn!(
200			log_lines = valuable(&log_lines),
201			extra_lines = valuable(&extra_lines),
202			%operation_name,
203			result_code = %returned_result_code,
204			"got an error from a result status page",
205		);
206	}
207
208	Ok(was_successful)
209}
210
211#[cfg(test)]
212mod unit_tests {
213	use super::*;
214
215	#[test]
216	pub fn can_parse_response_from_power_on_v2_on_14_80() {
217		// Yes. This is a real HTML response, returned from a real MION running,
218		// real, official firmware.
219		//
220		// Yes it is invalid HTML. Yes it is a mess. Yes it is not always returned
221		// based off the parameters passed.
222		assert!(
223			parse_result_from_body(
224				"<HTML>\r\nINFO: Enabling CATDEV mode...<br>\nINFO: no change in parameters<br>\nINFO: Enabling HSATA keep alive<br/>\nRESULT:OK<br>cafe powered on successfully, nAtt=0<br>\n</body>\n</HTML>",
225				"power_on_v2"
226			).expect("Failed to parse result from body!"),
227			"Expected successful responsee."
228		);
229	}
230}