use std::{
collections::HashMap,
io::Read,
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
use tiny_http::{Header, Request, Response};
use crate::{
scheduler::{Grade, keypoint_grade},
trace::Delta,
};
pub(super) fn read_delta(request: &mut Request) -> Option<Delta> {
#[derive(Deserialize)]
struct Body {
delta: String,
}
let body: Body = serde_json::from_reader(request.as_reader()).ok()?;
Delta::from_key(body.delta.chars().next()?)
}
pub(super) fn request_path(request: &Request) -> String {
request.url().split('?').next().unwrap_or("").to_string()
}
pub(super) fn is_authorized(
path: &str,
auth_header: Option<&str>,
query_token: Option<&str>,
token: Option<&str>,
) -> bool {
let Some(token) = token else { return true };
if !path.starts_with("/api/") {
return true;
}
let presented = auth_header
.and_then(|h| h.strip_prefix("Bearer "))
.or(query_token);
presented.is_some_and(|p| ct_eq(p.as_bytes(), token.as_bytes()))
}
pub(super) fn ct_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
pub(super) fn header_value<'a>(request: &'a Request, name: &'static str) -> Option<&'a str> {
request
.headers()
.iter()
.find(|h| h.field.equiv(name))
.map(|h| h.value.as_str())
}
pub(super) fn query_param(url: &str, key: &str) -> Option<String> {
let (_, query) = url.split_once('?')?;
query.split('&').find_map(|pair| {
let (k, v) = pair.split_once('=')?;
(k == key).then(|| v.to_string())
})
}
pub(super) fn content_type(path: &Path) -> &'static str {
match path
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("svg") => "image/svg+xml",
_ => "application/octet-stream",
}
}
pub(super) fn read_grade(request: &mut Request) -> Option<Grade> {
#[derive(Deserialize)]
struct Body {
grade: Option<String>,
covered: Option<usize>,
total: Option<usize>,
}
let body: Body = serde_json::from_reader(request.as_reader()).ok()?;
if let Some(g) = body.grade.as_deref() {
return match g {
"failed" => Some(Grade::Fail),
"partly" => Some(Grade::Partial),
"passed" => Some(Grade::Pass),
_ => None,
};
}
match (body.covered, body.total) {
(Some(covered), Some(total)) => Some(keypoint_grade(covered, total)),
_ => None,
}
}
pub(super) fn read_index(request: &mut Request) -> Option<usize> {
#[derive(Deserialize)]
struct Body {
index: usize,
}
let body: Body = serde_json::from_reader(request.as_reader()).ok()?;
Some(body.index)
}
pub(super) fn read_capped(reader: impl Read, cap: usize) -> Option<Vec<u8>> {
let mut bytes = Vec::new();
if reader.take(cap as u64 + 1).read_to_end(&mut bytes).is_err() || bytes.len() > cap {
None
} else {
Some(bytes)
}
}
pub(super) fn cache_header(policy: &'static [u8]) -> Header {
Header::from_bytes(&b"Cache-Control"[..], policy).unwrap()
}
pub(super) fn respond_json<T: Serialize>(request: Request, value: &T) {
let body = serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string());
let header = Header::from_bytes(
&b"Content-Type"[..],
&b"application/json; charset=utf-8"[..],
)
.unwrap();
let _ = request.respond(
Response::from_string(body)
.with_header(header)
.with_header(cache_header(b"no-store")),
);
}
pub(super) fn respond_html(request: Request, html: &str) {
let header =
Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]).unwrap();
let _ = request.respond(
Response::from_string(html.to_string())
.with_header(header)
.with_header(cache_header(b"no-cache")),
);
}
pub(super) fn respond_asset(request: Request, body: &str, content_type: &str) {
let header = Header::from_bytes(&b"Content-Type"[..], content_type.as_bytes()).unwrap();
let _ = request.respond(
Response::from_string(body.to_string())
.with_header(header)
.with_header(cache_header(b"no-cache")),
);
}
pub(super) fn respond_font(request: Request, bytes: &'static [u8]) {
let header = Header::from_bytes(&b"Content-Type"[..], &b"font/woff2"[..]).unwrap();
let _ = request.respond(
Response::from_data(bytes)
.with_header(header)
.with_header(cache_header(b"public, max-age=31536000, immutable")),
);
}
pub(super) fn respond_status(request: Request, code: u16) {
let _ = request.respond(Response::from_string(String::new()).with_status_code(code));
}
pub(super) fn respond_bytes(request: Request, bytes: Vec<u8>, content_type: &str) {
let header = Header::from_bytes(&b"Content-Type"[..], content_type.as_bytes()).unwrap();
let _ = request.respond(Response::from_data(bytes).with_header(header));
}
pub(super) fn download_filename(name: &str) -> String {
let safe: String = name
.chars()
.filter(|c| c.is_ascii() && !c.is_ascii_control() && *c != '"' && *c != '\\')
.collect();
let stem = match safe.rfind('.') {
Some(idx) => &safe[..idx],
None => safe.as_str(),
};
if !stem.chars().any(|c| c.is_ascii_alphanumeric()) {
"decks.zip".to_string()
} else {
safe
}
}
pub(super) fn normalize_md_extension(name: &str, lower_name: &str) -> String {
if lower_name.ends_with(".md") {
format!("{}.md", &name[..name.len() - 3])
} else {
name.to_string()
}
}
pub(super) fn respond_download(
request: Request,
bytes: Vec<u8>,
content_type: &str,
filename: &str,
) {
let content_type_header =
Header::from_bytes(&b"Content-Type"[..], content_type.as_bytes()).unwrap();
let disposition = format!("attachment; filename=\"{}\"", download_filename(filename));
let response = Response::from_data(bytes).with_header(content_type_header);
let _ = match Header::from_bytes(&b"Content-Disposition"[..], disposition.as_bytes()) {
Ok(disposition_header) => request.respond(response.with_header(disposition_header)),
Err(_) => request.respond(response),
};
}
pub(super) fn serve_image(request: Request, images: &HashMap<String, PathBuf>, key: &str) {
match images.get(key) {
Some(path) => match std::fs::read(path) {
Ok(bytes) => respond_bytes(request, bytes, content_type(path)),
Err(_) => respond_status(request, 404),
},
None => respond_status(request, 404),
}
}