Files
tuliprox/backend/src/api/model/request.rs
T
euzuandGitHub 9eef3b24e0 Feature/filter resolve probe (#720)
* Filter for resolve
* Filter for probe
2026-04-17 11:57:43 +02:00

485 lines
17 KiB
Rust

use axum::extract::{FromRequest, Request};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use http_body_util::LengthLimitError;
use log::log_enabled;
use std::error::Error as StdError;
const MAX_BODY_SIZE_BYTES: usize = 10 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
pub struct UserApiRequest {
#[serde(default)]
pub username: String,
#[serde(default)]
pub password: String,
#[serde(default)]
pub token: String,
#[serde(default)]
pub action: String,
#[serde(default)]
pub series_id: String,
#[serde(default)]
pub vod_id: String,
#[serde(default)]
pub stream_id: String,
#[serde(default)]
pub category_id: String,
#[serde(default)]
pub limit: String,
#[serde(default)]
pub start: String,
#[serde(default)]
pub end: String,
#[serde(default)]
pub stream: String,
#[serde(default)]
pub duration: String,
#[serde(default, alias = "type")]
pub content_type: String,
}
/// Custom extractor that parses `UserApiRequest` from query parameters and request body
/// (either `application/x-www-form-urlencoded` or `multipart/form-data`), then merges
/// them with query parameters taking priority over body fields.
pub struct UserApiRequestQueryOrBody(pub UserApiRequest);
#[derive(Debug)]
enum ParseBodyError {
PayloadTooLarge(String),
BadRequest(String),
}
impl ParseBodyError {
fn into_response(self) -> Response {
match self {
Self::PayloadTooLarge(err) => (StatusCode::PAYLOAD_TOO_LARGE, err).into_response(),
Self::BadRequest(err) => (StatusCode::BAD_REQUEST, err).into_response(),
}
}
}
impl<S> FromRequest<S> for UserApiRequestQueryOrBody
where
S: Send + Sync,
{
type Rejection = Response;
async fn from_request(req: Request, _state: &S) -> Result<Self, Self::Rejection> {
let (parts, body) = req.into_parts();
let query_req = parse_query_request(parts.uri.query())
.map_err(ParseBodyError::into_response)?;
let content_type = parts
.headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let body_req = match parse_body(body, content_type).await {
Ok(body_req) => Some(body_req),
Err(err) => return Err(err.into_response()),
};
Ok(UserApiRequestQueryOrBody(UserApiRequest::merge_query_over_form(&query_req, body_req.as_ref())))
}
}
async fn parse_body(body: axum::body::Body, content_type: &str) -> Result<UserApiRequest, ParseBodyError> {
let bytes = axum::body::to_bytes(body, MAX_BODY_SIZE_BYTES)
.await
.map_err(|e| {
if is_length_limit_error(&e) {
ParseBodyError::PayloadTooLarge(format!("Request body too large (max {MAX_BODY_SIZE_BYTES} bytes)"))
} else {
ParseBodyError::BadRequest(format!("Failed to read request body: {e}"))
}
})?;
if bytes.is_empty() {
return Ok(UserApiRequest::default());
}
if is_multipart_content_type(content_type) {
parse_multipart_body(&bytes, content_type)
} else {
// Treat as form-urlencoded (works for both explicit content-type and missing content-type)
serde_html_form::from_bytes(&bytes).map_err(|e| ParseBodyError::BadRequest(format!("Failed to parse form: {e}")))
}
}
fn parse_multipart_body(bytes: &[u8], content_type: &str) -> Result<UserApiRequest, ParseBodyError> {
let boundary = extract_multipart_boundary(content_type)
.ok_or_else(|| ParseBodyError::BadRequest("Missing boundary in multipart content type".to_string()))?;
let data = std::str::from_utf8(bytes).map_err(|e| ParseBodyError::BadRequest(format!("Invalid UTF-8: {e}")))?;
let mut request = UserApiRequest::default();
let delimiter = format!("--{boundary}");
for part in data.split(&delimiter) {
if let Some((name, value)) = parse_multipart_field(part) {
request.set_field(name, value);
}
}
Ok(request)
}
fn parse_query_request(query: Option<&str>) -> Result<UserApiRequest, ParseBodyError> {
match query {
Some(query) => serde_html_form::from_str(query)
.map_err(|e| ParseBodyError::BadRequest(format!("Failed to parse query: {e}"))),
None => Ok(UserApiRequest::default()),
}
}
fn parse_multipart_field(part: &str) -> Option<(&str, &str)> {
let header_end = part.find("\r\n\r\n")?;
let headers = &part[..header_end];
let body = &part[header_end + 4..];
let name = extract_multipart_field_name(headers)?;
let value = body.strip_suffix("\r\n").unwrap_or(body);
Some((name, value))
}
fn extract_multipart_field_name(headers: &str) -> Option<&str> {
let disposition = headers
.split("\r\n")
.find(|line| line.trim_start().to_ascii_lowercase().starts_with("content-disposition:"))?;
let (_, attrs) = disposition.split_once(':')?;
for attr in attrs.split(';').skip(1) {
let (name, value) = attr.trim().split_once('=')?;
if !name.trim().eq_ignore_ascii_case("name") {
continue;
}
let value = value.trim();
let unquoted_double = value.strip_prefix('"').and_then(|inner| inner.strip_suffix('"'));
if let Some(result) = unquoted_double {
return Some(result);
}
let unquoted_single = value.strip_prefix('\'').and_then(|inner| inner.strip_suffix('\''));
if let Some(result) = unquoted_single {
return Some(result);
}
return Some(value);
}
None
}
fn is_length_limit_error(err: &axum::Error) -> bool {
let mut current: Option<&(dyn StdError + 'static)> = Some(err);
while let Some(source) = current {
if source.is::<LengthLimitError>() {
return true;
}
current = source.source();
}
false
}
fn is_multipart_content_type(content_type: &str) -> bool {
content_type
.split(';')
.next()
.is_some_and(|media_type| media_type.trim().eq_ignore_ascii_case("multipart/form-data"))
}
fn extract_multipart_boundary(content_type: &str) -> Option<&str> {
for param in content_type.split(';').skip(1) {
let (name, value) = param.split_once('=')?;
if name.trim().eq_ignore_ascii_case("boundary") {
let trimmed = value.trim();
let unquoted = trimmed
.strip_prefix('"')
.and_then(|inner| inner.strip_suffix('"'))
.unwrap_or(trimmed);
if !unquoted.is_empty() {
return Some(unquoted);
}
}
}
None
}
impl UserApiRequest {
fn set_field(&mut self, name: &str, value: &str) {
match name {
"username" => self.username = value.to_string(),
"password" => self.password = value.to_string(),
"token" => self.token = value.to_string(),
"action" => self.action = value.to_string(),
"series_id" => self.series_id = value.to_string(),
"vod_id" => self.vod_id = value.to_string(),
"stream_id" => self.stream_id = value.to_string(),
"category_id" => self.category_id = value.to_string(),
"limit" => self.limit = value.to_string(),
"start" => self.start = value.to_string(),
"end" => self.end = value.to_string(),
"stream" => self.stream = value.to_string(),
"duration" => self.duration = value.to_string(),
"type" | "content_type" => self.content_type = value.to_string(),
_ => {}
}
}
pub fn merge_prefer_primary(primary: &Self, fallback: &Self) -> Self {
fn pick(primary: &str, fallback: &str) -> String {
if primary.trim().is_empty() {
fallback.to_string()
} else {
primary.to_string()
}
}
Self {
username: pick(&primary.username, &fallback.username),
password: pick(&primary.password, &fallback.password),
token: pick(&primary.token, &fallback.token),
action: pick(&primary.action, &fallback.action),
series_id: pick(&primary.series_id, &fallback.series_id),
vod_id: pick(&primary.vod_id, &fallback.vod_id),
stream_id: pick(&primary.stream_id, &fallback.stream_id),
category_id: pick(&primary.category_id, &fallback.category_id),
limit: pick(&primary.limit, &fallback.limit),
start: pick(&primary.start, &fallback.start),
end: pick(&primary.end, &fallback.end),
stream: pick(&primary.stream, &fallback.stream),
duration: pick(&primary.duration, &fallback.duration),
content_type: pick(&primary.content_type, &fallback.content_type),
}
}
pub fn merge_query_over_form(query: &Self, form: Option<&Self>) -> Self {
form.map_or_else(|| query.clone(), |form_req| Self::merge_prefer_primary(query, form_req))
}
pub fn log_sanitized(&self, endpoint: &str) {
if log_enabled!(log::Level::Debug) {
use std::fmt::Write;
let mut msg = endpoint.to_string();
if !self.username.is_empty() { let _ = write!(msg, " username={}", self.username); }
if !self.password.is_empty() { msg.push_str(" password=***"); }
if !self.token.is_empty() { msg.push_str(" token=***"); }
if !self.action.is_empty() { let _ = write!(msg, " action={}", self.action); }
if !self.series_id.is_empty() { let _ = write!(msg, " series_id={}", self.series_id); }
if !self.vod_id.is_empty() { let _ = write!(msg, " vod_id={}", self.vod_id); }
if !self.stream_id.is_empty() { let _ = write!(msg, " stream_id={}", self.stream_id); }
if !self.category_id.is_empty() { let _ = write!(msg, " category_id={}", self.category_id); }
if !self.limit.is_empty() { let _ = write!(msg, " limit={}", self.limit); }
if !self.start.is_empty() { let _ = write!(msg, " start={}", self.start); }
if !self.end.is_empty() { let _ = write!(msg, " end={}", self.end); }
if !self.stream.is_empty() { let _ = write!(msg, " stream={}", self.stream); }
if !self.duration.is_empty() { let _ = write!(msg, " duration={}", self.duration); }
if !self.content_type.is_empty() { let _ = write!(msg, " type={}", self.content_type); }
log::debug!("{msg}");
}
}
pub fn get_limit(&self) -> u32 {
if self.limit.is_empty() {
0
} else {
self.limit.parse::<u32>().unwrap_or(0)
}
}
}
#[cfg(test)]
mod tests {
use super::{parse_body, UserApiRequest, MAX_BODY_SIZE_BYTES};
use axum::body::Body;
use axum::extract::FromRequest;
use axum::http::{Request as HttpRequest, StatusCode};
#[test]
fn merge_prefer_primary_uses_fallback_for_empty_fields() {
let primary = UserApiRequest {
username: String::new(),
password: String::new(),
action: String::from("get_live_categories"),
..UserApiRequest::default()
};
let fallback = UserApiRequest {
username: String::from("xtr"),
password: String::from("xtr"),
..UserApiRequest::default()
};
let merged = UserApiRequest::merge_prefer_primary(&primary, &fallback);
assert_eq!(merged.username, "xtr");
assert_eq!(merged.password, "xtr");
assert_eq!(merged.action, "get_live_categories");
}
#[test]
fn merge_prefer_primary_uses_fallback_for_whitespace_only_fields() {
let primary = UserApiRequest {
username: String::from(" "),
password: String::from("\t"),
token: String::from("\n"),
action: String::from(" "),
series_id: String::from(" "),
vod_id: String::from(" "),
stream_id: String::from(" "),
category_id: String::from(" "),
limit: String::from(" "),
start: String::from(" "),
end: String::from(" "),
stream: String::from(" "),
duration: String::from(" "),
content_type: String::from(" "),
};
let fallback = UserApiRequest {
username: String::from("user"),
password: String::from("pass"),
token: String::from("token"),
action: String::from("action"),
series_id: String::from("series"),
vod_id: String::from("vod"),
stream_id: String::from("stream"),
category_id: String::from("category"),
limit: String::from("10"),
start: String::from("100"),
end: String::from("200"),
stream: String::from("300"),
duration: String::from("60"),
content_type: String::from("m3u_plus"),
};
let merged = UserApiRequest::merge_prefer_primary(&primary, &fallback);
assert_eq!(merged.username, "user");
assert_eq!(merged.password, "pass");
assert_eq!(merged.token, "token");
assert_eq!(merged.action, "action");
assert_eq!(merged.series_id, "series");
assert_eq!(merged.vod_id, "vod");
assert_eq!(merged.stream_id, "stream");
assert_eq!(merged.category_id, "category");
assert_eq!(merged.limit, "10");
assert_eq!(merged.start, "100");
assert_eq!(merged.end, "200");
assert_eq!(merged.stream, "300");
assert_eq!(merged.duration, "60");
assert_eq!(merged.content_type, "m3u_plus");
}
#[test]
fn merge_query_over_form_prefers_query_fields() {
let query = UserApiRequest {
username: String::from("query-user"),
token: String::from("query-token"),
action: String::from("query-action"),
..UserApiRequest::default()
};
let form = UserApiRequest {
username: String::from("form-user"),
token: String::from("form-token"),
action: String::from("form-action"),
..UserApiRequest::default()
};
let merged = UserApiRequest::merge_query_over_form(&query, Some(&form));
assert_eq!(merged.username, "query-user");
assert_eq!(merged.token, "query-token");
assert_eq!(merged.action, "query-action");
}
#[tokio::test]
async fn parse_body_rejects_oversized_payloads() {
let oversized = "a".repeat(MAX_BODY_SIZE_BYTES + 1);
let err = parse_body(Body::from(oversized), "application/x-www-form-urlencoded")
.await
.expect_err("oversized bodies should be rejected");
match err {
super::ParseBodyError::PayloadTooLarge(msg) => {
assert!(msg.contains("body too large"), "unexpected error: {msg}");
}
other@ super::ParseBodyError::BadRequest(_) => panic!("unexpected error: {other:?}"),
}
}
#[tokio::test]
async fn extractor_surfaces_oversized_body_as_bad_request() {
let oversized = "a".repeat(MAX_BODY_SIZE_BYTES + 1);
let request = HttpRequest::builder()
.header("content-type", "application/x-www-form-urlencoded")
.uri("/player_api.php")
.body(Body::from(oversized))
.expect("request should build");
let Err(response) = super::UserApiRequestQueryOrBody::from_request(request, &()).await else { panic!("oversized body should reject extractor") };
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn parse_body_detects_multipart_case_insensitively_and_preserves_field_whitespace() {
let body = concat!(
"--abc123\r\n",
"Content-Disposition: form-data; name=\"username\"\r\n\r\n",
" alice \r\n",
"--abc123\r\n",
"Content-Disposition: form-data; name=\"password\"\r\n\r\n",
"\t secret \t\r\n",
"--abc123--\r\n",
);
let parsed = parse_body(
Body::from(body),
"Multipart/Form-Data; charset=utf-8; boundary=\"abc123\"",
)
.await
.expect("multipart body should parse");
assert_eq!(parsed.username, " alice ");
assert_eq!(parsed.password, "\t secret \t");
}
#[tokio::test]
async fn parse_body_accepts_content_type_field_name_in_multipart() {
let body = concat!(
"--abc123\r\n",
"Content-Disposition: form-data; name=\"content_type\"\r\n\r\n",
"m3u_plus\r\n",
"--abc123--\r\n",
);
let parsed = parse_body(
Body::from(body),
"multipart/form-data; boundary=abc123",
)
.await
.expect("multipart body should parse");
assert_eq!(parsed.content_type, "m3u_plus");
}
#[tokio::test]
async fn parse_body_uses_content_disposition_name_not_filename() {
let body = concat!(
"--abc123\r\n",
"Content-Disposition: form-data; filename=\"name=\\\"wrong\\\".txt\"; name=\"username\"\r\n\r\n",
"alice\r\n",
"--abc123--\r\n",
);
let parsed = parse_body(
Body::from(body),
"multipart/form-data; boundary=abc123",
)
.await
.expect("multipart body should parse");
assert_eq!(parsed.username, "alice");
}
}