/* * Copyright (c) 2025, 2026 silt * SPDX-License-Identifier: AGPL-3.0-or-later * * This file is part of Mintee. * * Mintee is free software: you can redistribute it and/or modify it under the * terms of the GNU Affero General Public License as published by the Free * Software Foundation, either version 3 of the License, or (at your option) * any later version. * * Mintee is distributed in the hope that it will be useful, but WITHOUT ANY * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more * details. * * You should have received a copy of the GNU Affero General Public License * along with Mintee. If not, see https://www.gnu.org/licenses/. */ use httparse::{self}; use itertools::Itertools; use typed_path::{Utf8Component, Utf8UnixComponent, Utf8UnixPath, Utf8UnixPathBuf}; use std::{ error::Error, fmt, io::{self, BufRead, BufReader, Read}, net::{Incoming, SocketAddr, TcpListener, TcpStream, ToSocketAddrs}, ops::Deref, pin::Pin, process::exit, str::FromStr, time::Duration }; use mintee::util::yapper::{yap, eyap}; pub use super::manager::{Frontend, FrontendImpl}; #[derive(Debug, Clone, Copy)] #[non_exhaustive] pub enum HttpMethod { GET, POST, HEAD, PUT, DELETE, CONNECT, OPTIONS, TRACE, PATCH, Unknown, } impl From<&str> for HttpMethod { fn from(val: &str) -> Self { use HttpMethod::*; match val { "GET" => GET, "POST" => POST, "HEAD" => HEAD, "PUT" => PUT, "DELETE" => DELETE, "CONNECT" => CONNECT, "OPTIONS" => OPTIONS, "TRACE" => TRACE, "PATCH" => PATCH, _ => Unknown, } } } impl From for &'static str { fn from(val: HttpMethod) -> Self { use HttpMethod::*; match val { GET => "GET", POST => "POST", HEAD => "HEAD", PUT => "PUT", DELETE => "DELETE", CONNECT => "CONNECT", OPTIONS => "OPTIONS", TRACE => "TRACE", PATCH => "PATCH", Unknown => "?", } } } impl From for HttpMethod { fn from(val: String) -> Self { use HttpMethod::*; match val.as_str() { "GET" => GET, "POST" => POST, "HEAD" => HEAD, "PUT" => PUT, "DELETE" => DELETE, "CONNECT" => CONNECT, "OPTIONS" => OPTIONS, "TRACE" => TRACE, "PATCH" => PATCH, _ => Unknown, } } } impl From for String { fn from(val: HttpMethod) -> Self { use HttpMethod::*; match val { GET => "GET".to_string(), POST => "POST".to_string(), HEAD => "HEAD".to_string(), PUT => "PUT".to_string(), DELETE => "DELETE".to_string(), CONNECT => "CONNECT".to_string(), OPTIONS => "OPTIONS".to_string(), TRACE => "TRACE".to_string(), PATCH => "PATCH".to_string(), Unknown => "?".to_string(), } } } #[derive(Debug, Clone)] #[non_exhaustive] pub enum ResponseStatus { Okay, Created, MovedPermanently { location: String, }, SeeOther { location: String, }, TemporaryRedirect { location: String, }, PermanentRedirect { location: String, }, BadRequest, Unauthorized, Forbidden, NotFound, MethodNotAllowed { allow: Vec, }, UriTooLong, ImATeapot, InternalServerError, NotImplemented, HttpVersionNotSupported, } impl ResponseStatus { fn as_code(&self) -> usize { use ResponseStatus::*; match self { Okay => 200, Created => 201, MovedPermanently { .. } => 301, SeeOther { .. } => 303, TemporaryRedirect { .. } => 307, PermanentRedirect { .. } => 308, BadRequest => 400, Unauthorized => 401, Forbidden => 403, NotFound => 404, MethodNotAllowed { .. } => 405, UriTooLong => 414, ImATeapot => 418, InternalServerError => 500, NotImplemented => 501, HttpVersionNotSupported => 505, } } fn as_description(&self) -> &'static str { use ResponseStatus::*; match self { Okay => "OK", Created => "Created", MovedPermanently { .. } => "Moved Permanently", SeeOther { .. } => "See Other", TemporaryRedirect { .. } => "Temporary Redirect", PermanentRedirect { .. } => "Permanent Redirect", BadRequest => "Bad Request", Unauthorized => "Unauthorized", Forbidden => "Forbidden", NotFound => "Not Found", MethodNotAllowed { .. } => "Method Not Allowed", UriTooLong => "URI Too Long", ImATeapot => "I'm A Teapot", InternalServerError => "Internal Server Error", NotImplemented => "Not Implemented", HttpVersionNotSupported => "HTTP Version Not Supported", } } } #[derive(Debug, Clone)] pub struct HttpError { kind: ResponseStatus, } impl HttpError { pub fn new(kind: ResponseStatus) -> Self { Self { kind } } } impl fmt::Display for HttpError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, "HTTP/1.1 {} {}", self.kind.as_code(), self.kind.as_description() ) } } impl Error for HttpError {} impl From for io::Error { fn from(val: HttpError) -> Self { io::Error::other(val.to_string()) } } impl From for HttpError { fn from(_: httparse::Error) -> Self { HttpError::new(ResponseStatus::BadRequest) } } #[derive(Debug, Clone)] pub struct Response<'a> { pub status: ResponseStatus, pub headers: Vec<(&'a str, String)>, pub body: Option<&'a [u8]>, } impl<'a> From> for Vec { fn from(val: Response<'a>) -> Self { [ "HTTP/1.1 ".as_bytes(), val.status.as_code().to_string().as_bytes(), b" ", val.status.as_description().as_bytes(), b"\r\n", &val.headers.into_iter().fold( Default::default(), |mut acc: Vec, e: (&str, String)| -> Vec { acc.append(&mut [e.0.as_bytes(), b": ", e.1.as_bytes(), b"\r\n"].concat()); acc } ), b"\r\n", val.body.unwrap_or_default(), ].concat() } } impl<'a> From for Response<'a> { fn from(err: HttpError) -> Self { let status = err.kind.clone(); let headers = match err.kind { ResponseStatus::MovedPermanently { location } | ResponseStatus::SeeOther { location } | ResponseStatus::TemporaryRedirect { location } | ResponseStatus::PermanentRedirect { location } => vec![("location", location)], ResponseStatus::MethodNotAllowed { allow } => vec![( "allow", allow.iter().map(|x| Into::::into(*x)).join(", ") )], _ => vec![], }; Response { status, headers, body: None } } } pub struct FeConfig { bind_address: SocketAddr, read_timeout: Duration, write_timeout: Duration, } impl FeConfig { pub fn init( bind_address: A, read_timeout: Duration, write_timeout: Duration, ) -> Result> { Ok(FeConfig { bind_address: bind_address.to_socket_addrs()?.collect::>()[0], read_timeout, write_timeout, }) } } pub struct FeStorage { listener: TcpListener, // TODO: tera template store } impl Frontend { fn router( method: HttpMethod, path: Utf8UnixPathBuf, params: Option>, headers: &[httparse::Header], ) -> Result< as FrontendImpl>::Response, Box> { use HttpMethod::*; use ResponseStatus::*; // unwrapping is safe here because the resource path it came from is a valid UTF-8 &str match (method, path.components().map(|c| c.as_str()).collect::>().deref(), params, headers) { (method, ["/", "index.html"], _, _) => { if matches!(method, GET) { Ok(Response { status: ResponseStatus::Okay, headers: vec![("x-test1", "test1".to_string()), ("x-test2", "test2".to_string())], body: Some(b"totally cool and swag homepage"), }.into()) } else { Err(Box::new(HttpError::new(MethodNotAllowed { allow: vec![GET] }))) } } (method, ["/", "login"], _, _) => { if matches!(method, GET | POST) { todo!() } else { Err(Box::new(HttpError::new(MethodNotAllowed { allow: vec![GET, POST] }))) } } // oh how i long for inline const patterns (method, ["/", user], _, _) if let Some(user) = user.strip_prefix('~') => { if matches!(method, GET) { todo!() } else { Err(Box::new(HttpError::new(MethodNotAllowed { allow: vec![GET] }))) } } (method, ["/", user, repo], _, _) if let Some(user) = user.strip_prefix('~') => { if matches!(method, GET) { todo!() } else { Err(Box::new(HttpError::new(MethodNotAllowed { allow: vec![GET] }))) } } (method, ["/", project], _, _) if let Some(project) = project.strip_prefix('+') => { if matches!(method, GET) { todo!() } else { Err(Box::new(HttpError::new(MethodNotAllowed { allow: vec![GET] }))) } } (method, ["/", project, repo], _, _) if let Some(project) = project.strip_prefix('+') => { if matches!(method, GET) { todo!() } else { Err(Box::new(HttpError::new(MethodNotAllowed { allow: vec![GET] }))) } } _ => Err(Box::new(HttpError::new(ResponseStatus::NotFound))), } } } impl Iterator for Frontend { type Item = Incoming<'static>; fn next(&mut self) -> Option { todo!() } } impl FrontendImpl for Frontend { type FeConfig = FeConfig; type Request = TcpStream; type Response = Vec; fn init(config: FeConfig) -> Self { // TODO: load tera templates into FeStorage Frontend { storage: self::FeStorage { listener: TcpListener::bind(config.bind_address).unwrap_or_else(|e| { eyap!(&e); exit(1) }), }, config: config, } } fn handle_request(&self, subj: Self::Request) -> Result> { subj.set_read_timeout(Some(self.config.read_timeout)) .and_then(|_| subj.set_write_timeout(Some(self.config.write_timeout)))?; let stream_read = BufReader::new(subj); let mut headers = [httparse::EMPTY_HEADER; 32]; let mut req = httparse::Request::new(&mut headers); let buf: &mut Vec = &mut vec![]; // TODO: validate more of the request before sending to the router stream_read.take(8192).read_until(b'\n', buf)?; let res = req.parse(buf); Ok(match (res, req) { // Presumably well-formed enough to get sent off to the route handler ( Ok(httparse::Status::Partial), httparse::Request { method: Some(method), path: Some(path), version: Some(1), headers, }, ) => { // separate path containing get params into path and kv vec let (path, params) = path.split_once("?").map_or_else( || (Utf8UnixPathBuf::from_str(path).unwrap(), None), |(path, args)| { ( Utf8UnixPathBuf::from_str(path).unwrap(), Some( args.split('&') .filter_map(|e| e.split_once('=')) .collect::>(), ), ) }, ); if path.is_absolute() { // context-valid lexical normalization without da feature let path = Utf8UnixPathBuf::from_iter(path.components().try_fold(Vec::::new(), |mut acc, item| -> Result>, Box> { match item { Utf8UnixComponent::CurDir => Ok(acc), Utf8UnixComponent::RootDir => {acc.push(item); Ok(acc)}, Utf8UnixComponent::Normal(_) => {acc.push(item); Ok(acc)}, Utf8UnixComponent::ParentDir => {acc.pop_if(|c| c != &Utf8UnixComponent::RootDir); Ok(acc)}, } })?); Self::router(method.into(), path, params, headers) } else { Err(Box::new(HttpError::new(ResponseStatus::BadRequest)) as Box) } } // Malformed request lines and HTTP/1.1 requests without a Host header (Ok(httparse::Status::Partial), _) | (Ok(httparse::Status::Complete(_)), _) => { Err(Box::new(HttpError::new(ResponseStatus::BadRequest)) as Box) } // Fatal parsing error; obvious bad request (Err(e), _) => Err(Box::new(e) as Box), }?) } fn handle_error(&mut self, res: Result>) -> Vec { todo!() } }