partial code reorganization ; http_fe progress

This commit is contained in:
2026-03-05 22:46:15 +00:00
parent ac7d7a44c7
commit d17d64876a
12 changed files with 738 additions and 448 deletions

View File

@@ -0,0 +1,465 @@
/*
* Copyright (c) 2025, 2026 silt <silt@tebibyte.media>
* 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<HttpMethod> 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<String> 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<HttpMethod> 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<HttpMethod>,
},
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<HttpError> for io::Error {
fn from(val: HttpError) -> Self {
io::Error::other(val.to_string())
}
}
impl From<httparse::Error> 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<Response<'a>> for Vec<u8> {
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<u8>, e: (&str, String)| -> Vec<u8> {
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<HttpError> 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::<String>::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<A: ToSocketAddrs>(
bind_address: A,
read_timeout: Duration,
write_timeout: Duration,
) -> Result<Self, Box<dyn Error>> {
Ok(FeConfig {
bind_address: bind_address.to_socket_addrs()?.collect::<Vec<_>>()[0],
read_timeout,
write_timeout,
})
}
}
pub struct FeStorage {
listener: TcpListener,
// TODO: tera template store
}
impl Frontend<FeStorage, FeConfig> {
fn router(
method: HttpMethod,
path: Utf8UnixPathBuf,
params: Option<Vec<(&str, &str)>>,
headers: &[httparse::Header],
) -> Result<<Frontend<FeStorage, FeConfig> as FrontendImpl<TcpStream>>::Response, Box<dyn Error>> {
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::<Vec<&str>>().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<FeStorage, FeConfig> {
type Item = Incoming<'static>;
fn next(&mut self) -> Option<Self::Item> {
todo!()
}
}
impl FrontendImpl<TcpStream> for Frontend<FeStorage, FeConfig> {
type FeConfig = FeConfig;
type Request = TcpStream;
type Response = Vec<u8>;
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<Self::Response, Box<dyn Error>> {
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<u8> = &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::<Vec<(&str, &str)>>(),
),
)
},
);
if path.is_absolute() {
// context-valid lexical normalization without da feature
let path = Utf8UnixPathBuf::from_iter(path.components().try_fold(Vec::<Utf8UnixComponent>::new(), |mut acc, item| -> Result<Vec<Utf8UnixComponent<'_>>, Box<dyn Error>> {
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<dyn Error>)
}
}
// 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<dyn Error>)
}
// Fatal parsing error; obvious bad request
(Err(e), _) => Err(Box::new(e) as Box<dyn Error>),
}?)
}
fn handle_error(&mut self, res: Result<Self::Response, Box<dyn Error>>) -> Vec<u8> {
todo!()
}
}