use super::api;
use super::state::{Command, SharedState};
use crate::error::AppError;
use std::io::Read;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::Sender;
use std::time::Duration;
use tiny_http::{Header, Method, Request, Response, Server};
use tracing::warn;
const INDEX_HTML: &str = include_str!("web/index.html");
const STYLE_CSS: &str = include_str!("web/style.css");
const APP_JS: &str = include_str!("web/app.js");
const MAX_BODY: u64 = 1024 * 1024;
pub fn bind(listen: &str) -> Result<Server, AppError> {
Server::http(listen)
.map_err(|e| AppError::CommandExecution(format!("Failed to bind web server: {e}")))
}
pub fn serve(
server: &Server,
shared: &SharedState,
cmd_tx: &Sender<Command>,
shutdown: &AtomicBool,
) {
while !shutdown.load(Ordering::SeqCst) {
match server.recv_timeout(Duration::from_millis(500)) {
Ok(Some(request)) => handle_request(request, shared, cmd_tx),
Ok(None) => {}
Err(e) => warn!("HTTP accept error: {e}"),
}
}
}
fn handle_request(mut request: Request, shared: &SharedState, cmd_tx: &Sender<Command>) {
let url = request.url().to_string();
let (path, query) = url.split_once('?').unwrap_or((url.as_str(), ""));
let (status, body) = match (request.method(), path) {
(Method::Get, "/") => return respond_asset(request, INDEX_HTML, "text/html; charset=utf-8"),
(Method::Get, "/style.css") => {
return respond_asset(request, STYLE_CSS, "text/css; charset=utf-8");
}
(Method::Get, "/app.js") => {
return respond_asset(request, APP_JS, "application/javascript; charset=utf-8");
}
(Method::Get, "/api/status") => (200, api::status(shared)),
(Method::Get, "/api/queue") => (200, api::queue(shared)),
(Method::Get, "/api/fs") => api::fs_browse(
&query_param(query, "path").unwrap_or_default(),
query_param(query, "hidden").as_deref() == Some("1"),
),
(Method::Get, "/api/settings") => (200, api::settings_get(shared)),
(Method::Post, "/api/queue/add") => match read_json_body(&mut request) {
Ok(body) => api::queue_add(cmd_tx, &body),
Err(resp) => resp,
},
(Method::Post, "/api/queue/remove") => match read_json_body(&mut request) {
Ok(body) => api::queue_remove(shared, cmd_tx, &body),
Err(resp) => resp,
},
(Method::Post, "/api/queue/pause") => match read_json_body(&mut request) {
Ok(body) => api::queue_pause(cmd_tx, &body),
Err(resp) => resp,
},
(Method::Post, "/api/queue/cancel") => api::queue_cancel(cmd_tx),
(Method::Post, "/api/queue/clear_finished") => api::queue_clear_finished(shared, cmd_tx),
(Method::Post, "/api/settings") => match read_json_body(&mut request) {
Ok(body) => api::settings_post(cmd_tx, &body),
Err(resp) => resp,
},
_ => (404, serde_json::json!({"error": "not found"})),
};
respond_json(request, status, &body);
}
fn respond_json(request: Request, status: u16, body: &serde_json::Value) {
let response = Response::from_string(body.to_string())
.with_status_code(status)
.with_header(content_type("application/json"));
if let Err(e) = request.respond(response) {
warn!("Failed to send HTTP response: {e}");
}
}
fn respond_asset(request: Request, body: &'static str, mime: &str) {
let response = Response::from_string(body).with_header(content_type(mime));
if let Err(e) = request.respond(response) {
warn!("Failed to send HTTP response: {e}");
}
}
fn content_type(mime: &str) -> Header {
Header::from_bytes(&b"Content-Type"[..], mime.as_bytes()).expect("valid header")
}
fn read_json_body(request: &mut Request) -> Result<serde_json::Value, (u16, serde_json::Value)> {
let mut body = String::new();
request
.as_reader()
.take(MAX_BODY)
.read_to_string(&mut body)
.map_err(|e| (400, serde_json::json!({"error": format!("bad body: {e}")})))?;
serde_json::from_str(&body)
.map_err(|e| (400, serde_json::json!({"error": format!("invalid JSON: {e}")})))
}
fn query_param(query: &str, key: &str) -> Option<String> {
query.split('&').find_map(|pair| {
let (k, v) = pair.split_once('=')?;
(k == key).then(|| percent_decode(v))
})
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'%' if i + 2 < bytes.len()
&& bytes[i + 1].is_ascii_hexdigit()
&& bytes[i + 2].is_ascii_hexdigit() =>
{
let hi = (bytes[i + 1] as char).to_digit(16).unwrap_or(0);
let lo = (bytes[i + 2] as char).to_digit(16).unwrap_or(0);
out.push(u8::try_from(hi * 16 + lo).unwrap_or(b'%'));
i += 3;
}
b'+' => {
out.push(b' ');
i += 1;
}
b => {
out.push(b);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn percent_decoding() {
assert_eq!(percent_decode("/home/user/My%20Videos"), "/home/user/My Videos");
assert_eq!(percent_decode("a+b%2Fc"), "a b/c");
assert_eq!(percent_decode("plain"), "plain");
assert_eq!(percent_decode("bad%2"), "bad%2");
assert_eq!(percent_decode("%zz"), "%zz");
}
#[test]
fn query_params() {
assert_eq!(
query_param("path=%2Ftmp&hidden=1", "path").as_deref(),
Some("/tmp")
);
assert_eq!(query_param("path=%2Ftmp", "hidden"), None);
assert_eq!(query_param("", "path"), None);
}
}