From dd5b4c5238fb8d8d3c5f2645b1406f9e2717384d Mon Sep 17 00:00:00 2001 From: Emi Simpson Date: Sun, 31 Oct 2021 23:04:25 -0400 Subject: [PATCH] [web] [WIP] Initial progress in converting to SCGI --- web/Cargo.toml | 3 +- web/src/main.rs | 622 +++++++++++++++++++++++++++++++++------------ web/src/statics.rs | 46 ++-- 3 files changed, 475 insertions(+), 196 deletions(-) diff --git a/web/Cargo.toml b/web/Cargo.toml index 567113a..6391345 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -7,7 +7,8 @@ edition = "2021" [dependencies] pronouns_today = {path = ".."} -actix-web = "3" +async-scgi = "0.1.0" +smol = "1.2" log = "0.4" env_logger = "0.9" askama = "0.10" diff --git a/web/src/main.rs b/web/src/main.rs index 75ff4e0..3099108 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -3,57 +3,34 @@ pub mod contrast; pub mod statics; pub mod configuration; +use smol::io::AsyncWriteExt; +use smol::stream::StreamExt; +use std::borrow::Cow; +use pronouns_today::user_preferences::ParseError; +use pronouns_today::UserPreferences; use configuration::ConfigError; use std::net::SocketAddr; use std::process::exit; -use std::collections::HashMap; -use std::fmt::{self, Display}; +use std::ops::Deref; -use actix_web::dev::HttpResponseBuilder; -use actix_web::http::{header, StatusCode}; -use actix_web::middleware::normalize::TrailingSlash; -use actix_web::middleware::{self, Logger}; -use actix_web::web::resource; -use actix_web::{App, HttpRequest, HttpResponse, HttpServer, Responder, ResponseError, Result, post, web}; use argh; use askama::Template; +use async_scgi::{ScgiReadError, ScgiRequest}; use pronouns_today::user_preferences::Preference; use pronouns_today::{InstanceSettings, Pronoun}; +use smol; #[cfg(feature = "ogp_images")] use image::{DynamicImage, ImageOutputFormat}; #[cfg(feature = "ogp_images")] use ogp_images::render_today; -// TODO: Make this configurable -const HOSTNAME: &str = "pronouns.today"; - -#[derive(Template)] -#[template(path = "index.html")] -struct IndexTemplate<'a> { - name: Option, - pronoun: &'a Pronoun, - pronouns: Vec<(usize, &'a Pronoun)>, - url: String, -} - -fn render_page(pronoun: &Pronoun, settings: &InstanceSettings, name: Option, url: String) -> String { - IndexTemplate { - name, - pronoun, - pronouns: settings.pronoun_list.iter().enumerate().collect(), - url, - } - .render() - .unwrap() -} - -#[post("/")] +/* Ill deal with this later async fn create_link( - settings: web::Data, - form: web::Form>, -) -> Result { + settings: &InstanceSettings, + form: HashMap, +) -> String { let mut weights = vec![0; settings.pronoun_list.len()]; for (k, v) in form.iter() { if let Ok(i) = k.parse::() { @@ -69,82 +46,13 @@ async fn create_link( Some(name) if !name.is_empty() => format!("/{}/{}", name, pref_string), _ => format!("/{}", pref_string), }; - Ok(HttpResponse::SeeOther() - .header(header::LOCATION, url) - .finish()) -} + url +}*/ -fn form_full_url(host: &str, name: Option<&str>, prefstr: Option<&str>) -> String { - ["https:/", host].into_iter() - .chain(name) - .chain(prefstr) - .collect::>() - .join("/") -} +const NOT_FOUND: &[u8] = include_bytes!("../templates/404.html"); -/// Determine some basic information about a request -/// -/// Determines the name and prefstring properties, if available, and also computes the pronoun that -/// should be responded using. This method is designed to facilitate creating the basic response -/// pages. -/// -/// Both arguments should be passed directly from the caller, and the return values are, in order, -/// the prefstring, if available, the user's name, if available, and the computed pronouns. -fn get_request_info<'s, 'r>( - settings: &'s web::Data, - req: &'r HttpRequest, -) -> (Option<&'r str>, Option<&'r str>, Result<&'s Pronoun, Error>) { - let prefs = req.match_info().get("prefs"); - let name = req.match_info().get("name"); - let pronoun = settings - .select_pronouns(name, prefs) - .map_err(|_| Error::InvlaidPrefString); - (prefs, name, pronoun) -} - -async fn handle_basic_request( - settings: web::Data, - req: HttpRequest, -) -> Result { - - let (prefstr, name, pronoun) = get_request_info(&settings, &req); - - Ok(HttpResponse::Ok() - .content_type("text/html; charset=utf-8") - .body(render_page( - pronoun?, - &settings, - name.map(str::to_owned), - form_full_url(HOSTNAME, name, prefstr) - ))) -} - -#[cfg(feature = "ogp_images")] -async fn handle_thumbnail_request( - settings: web::Data, - req: HttpRequest, -) -> Result { - - let (_, name, pronoun) = get_request_info(&settings, &req); - - let mut data: Vec = Vec::with_capacity(15_000); - let image = DynamicImage::ImageRgb8(render_today(pronoun?, name.unwrap_or(""))); - image.write_to(&mut data, ImageOutputFormat::Png) - .expect("Error encoding thumbnail to PNG"); - - Ok(HttpResponse::Ok() - .content_type("image/png") - .body(data)) -} - -async fn not_found() -> impl Responder { - HttpResponse::NotFound() - .content_type("text/html; charset=utf-8") - .body(include_str!("../templates/404.html")) -} - -#[actix_web::main] -async fn main() -> std::io::Result<()> { +/// Handles initialization, and runs top-level routing based on cli args +fn main() { env_logger::init(); let args: configuration::PronounsTodayArgs = argh::from_env(); @@ -152,13 +60,14 @@ async fn main() -> std::io::Result<()> { match args.command { configuration::SubCommand::DumpStatics(_subargs) => { println!("Support for dumping statics not yet implemented"); - Ok(()) + return; } configuration::SubCommand::Run(subargs) => { let config = match subargs.load_config() { Ok(config) => config, Err(ConfigError::IoError(e)) => { - return Err(e); + eprintln!("IO Error while reading config! {}", e); + exit(1000); } Err(ConfigError::MalformedConfig(e)) => { eprintln!("Error parsing config file:\n{}", e); @@ -171,43 +80,438 @@ async fn main() -> std::io::Result<()> { exit(1002); } }; + + let executor = smol::LocalExecutor::new(); log::info!("Starting with configuration {:?}", config); - start_server(config).await + smol::block_on(executor.run(start_server(config, &executor))).unwrap(); } } } -async fn start_server(config: configuration::Conf) -> std::io::Result<()> { +/// Bind the server and start receiving connections. +/// +/// Each connection will be spawned into a task running on the provided executor. Each +/// connection is fully processed, but not in this method. Actual handling of the +/// requests is delegated to the [`handle_request()`], which is the actual task that is +/// spawned into the Executor. +async fn start_server(config: configuration::Conf, executor: &smol::LocalExecutor<'_>) -> std::io::Result<()> { // Where we binding bois let socket_addr = SocketAddr::new(config.address, config.port); println!("Starting pronouns-today-web on {}", &socket_addr); - HttpServer::new(move|| { - let logger = Logger::default(); - let app = App::new() - .data(config.instance_settings.clone()) - .wrap(logger) - .wrap(middleware::NormalizePath::new(TrailingSlash::Trim)) - .service(create_link); - let app = statics::STATIC_ASSETS.iter() - .fold(app, |app, asset| app.service(asset.generate_resource())); + let connection = smol::net::TcpListener::bind(socket_addr).await?; + let mut incoming = connection.incoming(); - #[cfg(feature = "ogp_images")] - let app = app - .service(resource("/thumb.png") .to(handle_thumbnail_request)) - .service(resource("/{prefs}/thumb.png") .to(handle_thumbnail_request)) - .service(resource("/{name}/{prefs}/thumb.png").to(handle_thumbnail_request)); + // Make the instance settings immortal + let instance_settings = Box::leak(Box::new(config.instance_settings)); - app - .service(resource("/") .to(handle_basic_request)) - .service(resource("/{prefs}") .to(handle_basic_request)) - .service(resource("/{name}/{prefs}").to(handle_basic_request)) - .default_service(web::to(not_found)) - }) - .bind(&socket_addr)? - .run() - .await + while let Some(stream) = incoming.next().await { + match stream { + Ok(stream) => { + executor.spawn(handle_request(stream, instance_settings)).detach(); + }, + Err(e) => { + log::error!("IO Error with client/server connection: {}", e); + } + } + } + + Ok(()) +} + +/// Handle one (1) connection from start to finish +/// +/// This accepts an ongoing connection, sets up IO, interprets it as an SCGI request, then +/// determines what response to send, generates the response, serializes the response, and +/// sends it back out to the client. +/// +/// Implementation details: +/// - Interpretting the request is delegated to [`async_scgi::read_request()`] +/// - Routing of the request is handled using [`route_request()`] +/// - Generation of the response is done using [`Route::generate_response()`] +/// - Serialization of the response is done using [`Response::into_bytes()`] +async fn handle_request( + raw_stream: impl smol::io::AsyncRead + smol::io::AsyncWrite + Unpin, + settings: &InstanceSettings +) { + let mut stream = smol::io::BufReader::new(raw_stream); + + let req = match async_scgi::read_request(&mut stream).await { + Ok(req) => req, + Err(ScgiReadError::IO(e)) => { + log::error!("There was an IO error while reading a request. {}", e); + exit(1003); + }, + Err(scgi_error) => { + log::error!("Encountered a malformed SCGI request. It could be that your \ + webserver is misconfigured. {}", scgi_error); + todo!(); //TODO send a 500 error + }, + }; + + if let Err(e) = stream.write_all( + route_request(&req) + .generate_response(settings) + .into_bytes() + .as_ref() + ).await { + log::warn!( + "Encountered an IO error while sending response: {}", e + ); + } +} + +/// Determine the appropriate response type for this request, aka perform routing +/// +/// For a given [`ScgiRequest`], attempt to figure out what route it falls under using a +/// variety of factors. This also extracts important information from the request that is +/// ultimately used to serve the request. +/// +/// See information about the return type, [`Route`] for more information. +fn route_request(req: &ScgiRequest) -> Route { + let method = req.headers + .get("REQUEST_METHOD") + .expect("Misconfigured server didn't send a REQUEST_METHOD header"); + + match method.as_str() { + // GET requests are dealt with as below + "GET" => (), + + // All POST requests are met with link generation + "POST" => return Route::GenerateLink, + + // No other methods are supported + _ => return Route::BadRequest(BadRequest::MethodNotAllowed), + } + + let mut segments: Vec<_> = req.headers + .get("REQUEST_URI") + .expect("Misconfigured server didn't send a REQUEST_URI header") + .split("/") + .filter(|s| s.len() > 0) + .collect(); + + #[cfg(feature = "embed_static_assets")] + // If the route is /static/, check to see if it matches an asset first + if segments.get(0).map(Deref::deref) == Some("static") { + // TODO try serve static files + } + + // Determines if we need to respond with a thumbnail or a web page + let is_thumb = + !segments.is_empty() && + segments.last().map(Deref::deref) == Some("thumb.png"); + if is_thumb { + // Now that we've determined that, we don't need this segment anymore + segments.pop(); + } + + // Get the name and preferences + let (name, prefs) = match (segments.get(0), segments.get(1)) { + (None, None) => (None, None), + (name@Some(_), Some(prefstr)) => { + (name, match prefstr.parse() { + Ok(prefs) => Some(prefs), + Err(e) => return Route::BadRequest(BadRequest::MalformedPrefstr(e)), + }) + }, + (Some(idk), None) => { + // Try to parse the mystery arg as a prefstring, treat it as a name otherwise + let result: Result = idk.parse(); + if let Ok(preferences) = result { + (None, Some(preferences)) + } else { + (Some(idk), None) + } + }, + (None, Some(_)) => unreachable!(), + }; + + let response_type = if is_thumb { + Route::SendThumbnail + } else { + Route::SendPronounPage + }; + + response_type(name.map(|n| (*n).to_owned()), prefs) +} + +/// Determines the type of route that the request should be met with, and necessary data +/// +/// Used to facilitate request routing. The routing function ([`route_request()`]) can +/// return a [`Route`] without needing to do any IO or actual computation. This helps to +/// seperate the tasks of each function, and isolate any IO. +/// +/// Each variant of the enum is a type of response that needs to be issued. A variant +/// might have parameters if these are necessary to generate the response. The idea is +/// that the minimum of work should be able to determine the route of a request, and that +/// a route may be turned into a response without needing any additional information from +/// the request. +/// +/// The `impl` of struct also contains all of the routes themselves. You can either call +/// a specific route using the contents of the [`Route`] enum, or call +/// [`Route.generate_response()`] to automatically call the appropriate route. +enum Route { + + #[cfg(feature = "embed_static_assets")] + /// Respond to the request with one of the static files embeded in the application + /// + /// The wrapped pointer is a pointer to those bytes in memory. + SendStatic(&'static [u8]), + + /// Respond with an HTML pronoun page. + /// + /// Takes the user's name (if specified), and the preferences of that user, or + /// [`None`] for defults + SendPronounPage(Option, Option), + + /// Respond with an image containing the user's pronouns + /// + /// Takes the user's name (optional), and the preferences of that user + SendThumbnail(Option, Option), + + /// Generate a link to the appropriate pronoun page based on the user's inputs. + GenerateLink, //TODO + + /// There was a problem with the user's request, and an error page should be sent + BadRequest(BadRequest), +} + +/// Various ways clients can send a bad request +enum BadRequest { + /// The user sent a request with a nonsensical HTTP method + MethodNotAllowed, + + /// The prefstr the user sent was bad and stinky + MalformedPrefstr(ParseError), + + /// Happens when [`ParseError::PrefstringExceedsPronounCount`] is raised + PrefsExceedPronounCount, + + /// Happens when the user's preferences specify that no pronouns should be used + /// + /// Triggered by a [`ParseError::EmptyWeightedTable`] + /// + /// TODO maybe add a special display for this instead of calling it a bad request? + NoPronouns, +} + +impl Route { + /// Actually perform the action that each route entails + fn generate_response(self, settings: &InstanceSettings) -> Response { + let result = match self { + Route::SendStatic(data) => Ok(Response { + status: 200, + headers: Cow::Borrowed(&[ + ]), + body: data.into(), + }), + Route::SendPronounPage(name, prefs) => + Route::send_pronoun_page(name, prefs, settings), + Route::SendThumbnail(name, prefs) => + Route::send_thumbnail(name, prefs, settings), + Route::GenerateLink => + Route::generate_link(settings), + Route::BadRequest(error) => + Ok(Route::bad_request(error, settings)), + }; + + result.unwrap_or_else(|e| Route::bad_request(e, settings)) + } + + /// The method for the [`Route::SendPronounPage`] route + fn send_pronoun_page( + name: Option, + prefs: Option, + settings: &InstanceSettings, + ) -> Result { + let pronoun = Route::get_pronoun(name.as_ref(), prefs, settings)?; + + let body = IndexTemplate { + name, + pronoun, + pronouns: settings.pronoun_list.iter().enumerate().collect(), + url: String::new(), //TODO + } + .render() + .unwrap(); + + Ok(Response { + status: 200, + headers: Cow::Borrowed(&[]), + body: body.into_bytes().into(), + }) + } + + #[cfg(feature = "ogp_images")] + /// The method for the [`Route::SendThumbnail`] route + fn send_thumbnail( + name: Option, + prefs: Option, + settings: &InstanceSettings, + ) -> Result { + let pronoun = Route::get_pronoun(name.as_ref(), prefs, settings)?; + + let mut data: Vec = Vec::with_capacity(15_000); + let image = DynamicImage::ImageRgb8(render_today(pronoun, name.as_ref().map(String::as_str).unwrap_or(""))); + image.write_to(&mut data, ImageOutputFormat::Png) + .expect("Error encoding thumbnail to PNG"); + + Ok(Response { + status: 200, + headers: Cow::Borrowed(&[ + (b"Eat", Cow::Borrowed(b"the rich!")), + ]), + body: data.into(), + }) + } + + /// The method for the [`Route::GenerateLink`] route + fn generate_link( + _settings: &InstanceSettings, + ) -> Result { + todo!() + } + + /// The method for the [`Route::BadRequest`] route + fn bad_request( + error: BadRequest, + _settings: &InstanceSettings + ) -> Response { + match error { + BadRequest::MethodNotAllowed => { + Response { + status: 405, + headers: Cow::Borrowed(&[ + (b"Cache-Control", Cow::Borrowed(b"max-age=999999")), + (b"And-Dont", Cow::Borrowed(b"Come Back!")), + ]), + body: Cow::Borrowed(b"405 Method Not Allowed\nGET OUTTA HERE"), + } + }, + BadRequest::MalformedPrefstr(_) => { + Response { + status: 404, + headers: Cow::Borrowed(&[ + (b"Cache-Control", Cow::Borrowed(b"max-age=631")), + (b"Help-Im", Cow::Borrowed(b"Trapped in an HTTP header factory!")), + ]), + body: NOT_FOUND.into(), + } + }, + BadRequest::PrefsExceedPronounCount => { + let body = ErrorPage::from_msg( + "Yoinkers Kersploinkers!! You know about more pronouns than \ + us. Either you tried to copy a prefstring from another \ + pronouns.today instance, or our admin was VERY NAUGHTY and CANT \ + READ BASIC DIRECTIONS" + ); + + Response { + status: 400, + headers: Cow::Borrowed(&[ + (b"I-Killed-God", Cow::Borrowed(b"And drank His blood")), + (b"Now", Cow::Borrowed(b"Realty quivers before my visage")), + ]), + body: body.into(), + } + }, + BadRequest::NoPronouns => { + let body = ErrorPage { + msg: "Someday we'll implement support for having no pronouns. You \ + should probably yell at the devs to make us put this in.".into() + } + .render() + .unwrap(); + + Response { + status: 400, + headers: Cow::Borrowed(&[ + (b"Cache-Control", Cow::Borrowed(b"max-age=540360")), + (b"Actually-Tho-Please-Dont-Yell-At-Me", Cow::Borrowed(b"ill cry :(")), + ]), + body: body.into_bytes().into(), + } + }, + } + } + + /// Not a route, but a handy method to have around + /// + /// Provides a convinient way to get pronouns out of some of the common route + /// components, and automatically converts the error into a harmless [`BadRequest`], + /// is present. + fn get_pronoun<'s>( + name: Option<&String>, + prefs: Option, + settings: &'s InstanceSettings, + ) -> Result<&'s Pronoun, BadRequest> { + // TODO use instance defaults here + let prefs = prefs.unwrap_or_else(|| "acaqeawdym".parse().unwrap()); + match prefs.select_pronoun( + settings, + name.map(String::as_str), + ) { + Ok(p) => Ok(p), + Err(ParseError::PrefstringExceedsPronounCount) => { + Err(BadRequest::PrefsExceedPronounCount) + }, + Err(ParseError::EmptyWeightedTable) => { + Err(BadRequest::NoPronouns) + }, + Err(_) => unreachable!(), + } + } +} + +/// A response to be sent back to the SCGI client +/// +/// Technically just an HTTP response but don't tell me that. Provides comfy little +/// methods for serializing itself right back into a series of bytes +pub struct Response { + /// The status of the response. Exactly the same as an HTTP status code + status: u16, + + /// A series of HTTP headers to send to the client + /// + /// wow emi that's a lot of cows SHUT UP I JUST WANNA CONSTRUCT THINGS IN CONST IS + /// THAT TOO MUCH TO ASK + headers: Cow<'static, [(&'static [u8], Cow<'static, [u8]>)]>, + + /// The body of the response. Wow what a useful doccomment Emi + body: Cow<'static, [u8]>, +} + +impl Response { + fn into_bytes(self) -> Vec { + let mut output = Vec::with_capacity( + // Is computing the exact length of the output necessary? No. + // Do we gain any even vaguely measurable performance gain by preventing reallocation? No. + // Am I doing it anyway? Yes. + // It is late and I am tired and you are nothing to me you can't tell me what to do + 14 + // HTTP/1.1 200\r\n + self.headers + .iter() + .map(|(h1, h2)| h1.len() + h2.len() + 4) + .sum::() + + 2 + // \r\n + self.body.len() + ); + + output.extend_from_slice(b"HTTP/1.1 "); + output.extend_from_slice(self.status.to_string().as_bytes()); + output.extend_from_slice(b"\r\n"); + for (h1, h2) in self.headers.iter() { + output.extend_from_slice(h1); + output.extend_from_slice(b": "); + output.extend_from_slice(h2); + output.extend_from_slice(b"\r\n"); + } + output.extend_from_slice(b"\r\n"); + output.extend_from_slice(&*self.body); + return output; + } } #[derive(Template)] @@ -216,36 +520,22 @@ struct ErrorPage { msg: String, } -#[derive(Debug)] -enum Error { - InvlaidPrefString, +impl ErrorPage { + fn from_msg(msg: &str) -> Vec { + ErrorPage { + msg: msg.into() + } + .render() + .unwrap() + .into_bytes() + } } -impl Display for Error { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let msg = match self { - &Error::InvlaidPrefString => "This URL contains an invalid pronoun preference string", - }; - write!(f, "{}", msg) - } -} - -impl ResponseError for Error { - fn status_code(&self) -> actix_web::http::StatusCode { - match self { - &Error::InvlaidPrefString => StatusCode::BAD_REQUEST, - } - } - - fn error_response(&self) -> HttpResponse { - HttpResponseBuilder::new(self.status_code()) - .set_header(header::CONTENT_TYPE, "text/html; charset=utf-8") - .body( - ErrorPage { - msg: self.to_string(), - } - .render() - .unwrap(), - ) - } +#[derive(Template)] +#[template(path = "index.html")] +struct IndexTemplate<'a> { + name: Option, + pronoun: &'a Pronoun, + pronouns: Vec<(usize, &'a Pronoun)>, + url: String, } diff --git a/web/src/statics.rs b/web/src/statics.rs index 0b89ccb..39b2ddb 100644 --- a/web/src/statics.rs +++ b/web/src/statics.rs @@ -1,10 +1,8 @@ -use actix_web::http::StatusCode; -use actix_web::HttpResponse; -use actix_web::HttpRequest; -use actix_web::web::resource; -use actix_web::Resource; -const DEFAULT_CACHE: &str = "max-age=86400"; +use std::borrow::Cow; +use crate::Response; + +const DEFAULT_CACHE: &[u8] = b"max-age=86400"; /// Represents a single static asset /// @@ -22,30 +20,20 @@ pub struct StaticAsset { } impl StaticAsset { - /// Generate a actix resource for serving this asset - /// - /// The resource will handle requests at `/static/{filename}`. Caching headers are - /// applied to allow the content to be considered fresh up to one day, and are - /// validated against the crate version after that. - pub fn generate_resource(&self) -> Resource { - let bytes = self.bytes; - resource(format!("/static/{}", self.filename)) - .to(move|req: HttpRequest| async move { - let mut response = HttpResponse::Ok(); - response - .header("Etag", env!("CARGO_PKG_VERSION")) - .header("Cache-Control", DEFAULT_CACHE); + const STATIC_HEADERS: &'static [(&'static [u8], Cow<'static, [u8]>)] = &[ + (b"Cache-Control", Cow::Borrowed(DEFAULT_CACHE)), + (b"Trans-People", Cow::Borrowed(b"Are Gods")), + ]; - let req_etag = req.headers().get("If-None-Match"); - match req_etag { - Some(etag) if etag == env!("CARGO_PKG_VERSION") => { - response.status(StatusCode::from_u16(304).unwrap()).finish() - } - _ => { - response.body(bytes) - } - } - }) + /// Generate the HTTP response for sending this asset to the client + // I wrote all this code to make this a const fn, and then don't even use it in + // compile-time :( + const fn generate_response(&self) -> Response { + Response { + status: 200, + headers: Cow::Borrowed(StaticAsset::STATIC_HEADERS), + body: Cow::Borrowed(self.bytes), + } } }