diff --git a/web/Cargo.toml b/web/Cargo.toml index 567113a..f7e0c50 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -7,13 +7,19 @@ edition = "2021" [dependencies] pronouns_today = {path = ".."} -actix-web = "3" +async-scgi = "0.1.0" +async-executor = "1.4" +async-net = "1.6" +async-io = "1.6" +futures-lite = "1.12" log = "0.4" env_logger = "0.9" askama = "0.10" argh = "0.1.6" serde = "1.0" serde_yaml = "0.8" +form_urlencoded = "1.0.1" +percent-encoding = "2.1.0" rusttype = { version = "0.9.2", optional = true } image = { version = "0.23.14", optional = true } lazy_static = { version = "1.4.0", optional = true } diff --git a/web/assets/default_config.yml b/web/assets/default_config.yml index 7a01e9a..12e6136 100644 --- a/web/assets/default_config.yml +++ b/web/assets/default_config.yml @@ -6,6 +6,13 @@ port: 1312 # The address the server should bind to address: 0.0.0.0 +# The base URL the server will be running under, with no trailing slash +# +# This is also your opportunity to serve requests under a subpath. For example, if you +# want pronouns.today to only run on the /pronouns route of your webserver, you can set +# this to https://example.com/pronouns +base_url: https://pronouns.today + # A list of pronouns recognized by the server # # WARNING: When adding pronouns, only add pronouns to the bottom of the list, and do not diff --git a/web/src/configuration.rs b/web/src/configuration.rs index 558c68e..85f4855 100644 --- a/web/src/configuration.rs +++ b/web/src/configuration.rs @@ -1,5 +1,3 @@ -use std::net::Ipv4Addr; -use std::net::IpAddr; use std::path::PathBuf; use pronouns_today::PronounList; use pronouns_today::UserPreferences; @@ -64,13 +62,10 @@ pub struct Run { /// options will be filled in using sane defaults pub no_read_cfg: bool, - #[argh(option, short = 'p')] - /// the port to listen on - pub port: Option, - #[argh(option)] - /// the address to bind to - pub address: Option, + /// the address to bind to. can be an ip address and a port, like 0.0.0.0:1312, or a + /// unix socket like /run/programming.sock. defaults to 0.0.0.0:1312 + pub bind: Option, #[argh(option)] /// default pronoun probabilites (formatted as a prefstring, like the ones in the @@ -82,6 +77,11 @@ pub struct Run { /// for warnings when changing this value. formatted as a list of comma seperated /// five-form pronouns, e.g. she/her/her/hers/herself,he/him/his/his/himself pub pronouns: Option, + + #[argh(option, long="base")] + /// the base url content is served under, starting with the protocol (https://) and without a + /// trailing slash + pub base_url: Option, } impl Run { @@ -129,15 +129,19 @@ pub struct Conf { /// The port for the server to bind to. Defaults to 1312 pub port: u16, - /// The address to bind to. Defaults to 0.0.0.0 - pub address: IpAddr, + /// The address to bind to. Can be an ip address and a port, like 0.0.0.0:1312, or a + /// unix socket like /run/programming.sock. Defaults to 0.0.0.0:1312 + pub bind: String, + + /// The base url the server will be running under, with no trailing slash + pub base_url: String, } impl Conf { fn update_with(mut self, args: Run) -> Conf { - self.port = args.port.unwrap_or(self.port); - self.address = args.address.unwrap_or(self.address); + self.bind = args.bind.unwrap_or(self.bind); self.instance_settings.pronoun_list = args.pronouns.map(Into::into).unwrap_or(self.instance_settings.pronoun_list); + self.base_url = args.base_url.unwrap_or(self.base_url); self } } @@ -147,7 +151,8 @@ impl Default for Conf { Conf { instance_settings: InstanceSettings::default(), port: 1312, - address: IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), + bind: "0.0.0.0:1312".into(), + base_url: "https://pronouns.today".into(), } } } diff --git a/web/src/main.rs b/web/src/main.rs index 75ff4e0..9b69827 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -2,22 +2,32 @@ pub mod ogp_images; pub mod contrast; pub mod statics; pub mod configuration; +mod write_vectored_all; +use async_net::unix::UnixListener; +use crate::configuration::Conf; +use std::io::IoSlice; +use crate::statics::StaticAsset; +use async_io::block_on; +use async_net::TcpListener; +use async_executor::LocalExecutor; +use futures_lite::io::AsyncWriteExt; +use futures_lite::stream::StreamExt; +use std::borrow::Cow; +use form_urlencoded; +use percent_encoding::{percent_decode_str, percent_encode, NON_ALPHANUMERIC}; +use pronouns_today::user_preferences::ParseError; +use pronouns_today::UserPreferences; use configuration::ConfigError; +use write_vectored_all::AsyncWriteAllVectored; 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}; @@ -26,125 +36,10 @@ use image::{DynamicImage, ImageOutputFormat}; #[cfg(feature = "ogp_images")] use ogp_images::render_today; -// TODO: Make this configurable -const HOSTNAME: &str = "pronouns.today"; +const NOT_FOUND: &[u8] = include_bytes!("../templates/404.html"); -#[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("/")] -async fn create_link( - settings: web::Data, - form: web::Form>, -) -> Result { - let mut weights = vec![0; settings.pronoun_list.len()]; - for (k, v) in form.iter() { - if let Ok(i) = k.parse::() { - let w = v.parse::().map_err(|_| Error::InvlaidPrefString)?; - if i < weights.len() - 1 { - weights[i] = w; - } - } - } - let prefs = InstanceSettings::create_preferences(&weights); - let pref_string = prefs.as_prefstring(); - let url = match form.get("name") { - Some(name) if !name.is_empty() => format!("/{}/{}", name, pref_string), - _ => format!("/{}", pref_string), - }; - Ok(HttpResponse::SeeOther() - .header(header::LOCATION, url) - .finish()) -} - -fn form_full_url(host: &str, name: Option<&str>, prefstr: Option<&str>) -> String { - ["https:/", host].into_iter() - .chain(name) - .chain(prefstr) - .collect::>() - .join("/") -} - -/// 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 +47,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 +67,581 @@ async fn main() -> std::io::Result<()> { exit(1002); } }; + + let executor = LocalExecutor::new(); log::info!("Starting with configuration {:?}", config); - start_server(config).await + 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: &LocalExecutor<'_>) -> std::io::Result<()> { + + // Make the configuration immortal + let config = Box::leak(Box::new(config)); + // Where we binding bois - let socket_addr = SocketAddr::new(config.address, config.port); + if let Ok(socket_addr) = config.bind.parse() as Result { - 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); + println!("Starting pronouns-today-web on {}", &socket_addr); - let app = statics::STATIC_ASSETS.iter() - .fold(app, |app, asset| app.service(asset.generate_resource())); + let connection = 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)); + while let Some(stream) = incoming.next().await { + match stream { + Ok(stream) => { + executor.spawn(handle_request(stream, config)).detach(); + }, + Err(e) => { + log::error!("IO Error with client/server connection: {}", e); + } + } + } - 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 + } else { + let bind = config.bind.trim_start_matches("unix:"); + let listener = UnixListener::bind(bind)?; + + println!("Listening for connections on unix:{}", bind); + + let mut incoming = listener.incoming(); + while let Some(stream) = incoming.next().await { + match stream { + Ok(stream) => { + executor.spawn(handle_request(stream, config)).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 futures_lite::AsyncRead + futures_lite::AsyncWrite + Unpin, + conf: &Conf, +) { + let mut stream = futures_lite::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); + return + }, + }; + + let response = route_request(&req) + .generate_response(conf); + let mut io_slices = response.into_io_slices(); + + if let Err(e) = stream.write_all_vectored(&mut io_slices).await { + log::warn!("Encountered an IO error while sending response: {}", e); + } + if let Err(e) = stream.close().await { + log::warn!( + "Encountered an IO error while closing connection to server: {}", 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(req.body.clone()), + + // No other methods are supported + _ => return Route::BadRequest(BadRequest::MethodNotAllowed), + } + + let request_uri = req.headers + .get("REQUEST_URI") + .expect("Misconfigured server didn't send a REQUEST_URI header"); + log::debug!("REQUEST_URI: {}", request_uri); + let mut segments: Vec<_> = request_uri + .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") { + for asset in statics::STATIC_ASSETS { + if Some(asset.filename) == segments.get(1).map(Deref::deref) { + return Route::SendStatic(asset); + } + } + } + + #[derive(PartialEq, Eq)] + enum RouteType { + Html, + Thumbnail, + Json, + } + + // Determines if we need to respond with a thumbnail or a web page + let route_type = match segments.last().map(Deref::deref) { + Some("thumb.png") => RouteType::Thumbnail, + Some("pns.json") => RouteType::Json, + _ => RouteType::Html, + }; + if route_type != RouteType::Html { + // 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 name = name.map(|n| (*n).to_owned()); + + match route_type { + RouteType::Thumbnail => Route::SendThumbnail(name, prefs), + RouteType::Html => { + let uri = request_uri.trim_end_matches('/').to_owned(); + Route::SendPronounPage(name, prefs, uri) + } + RouteType::Json => Route::SendJsonData(name, 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 StaticAsset), + + /// Respond with an HTML pronoun page. + /// + /// Takes the user's name (if specified), and the preferences of that user, or + /// [`None`] for defults. The third string is the URI of the current page, + /// ending without a / + SendPronounPage(Option, Option, String), + + /// Respond with an image containing the user's pronouns + /// + /// Takes the user's name (optional), and the preferences of that user + SendThumbnail(Option, Option), + + /// Respond with a json object containing the users pronouns + /// + /// Takes the user's name (optional), and the preferences of that user + SendJsonData(Option, Option), + + /// Generate a link to the appropriate pronoun page based on the user's inputs. + GenerateLink(Vec), + + /// 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, + + /// The data sent in a form was malformed. The string is the part that failed to parse + BadFormData(String), + + /// 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, conf: &Conf) -> Response { + let settings = &conf.instance_settings; + let result = match self { + Route::SendStatic(data) => Ok(data.generate_response()), + Route::SendPronounPage(name, prefs, url) => + Route::send_pronoun_page(name, prefs, url, conf), + Route::SendJsonData(name, prefs) => + Route::send_pronoun_json(name, prefs, conf), + Route::SendThumbnail(name, prefs) => + Route::send_thumbnail(name, prefs, settings), + Route::GenerateLink(body) => + Route::generate_link(body, 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, + uri: String, + conf: &Conf, + ) -> Result { + let settings = &conf.instance_settings; + let pronoun = Route::get_pronoun(name.as_ref(), prefs, settings)?; + + let body = IndexTemplate { + name: name.map( + |name| percent_decode_str(&name) + .decode_utf8_lossy() + .into_owned() + ), + pronoun, + pronouns: settings.pronoun_list.iter().enumerate().collect(), + url: format!("{}{}", &conf.base_url, uri), + } + .render() + .unwrap(); + + Ok(Response { + status: b"200", + headers: Cow::Borrowed(&[ + (b"Trans-Women", Cow::Borrowed(b"don't owe you femininity")), + (b"Trans-Men", Cow::Borrowed(b"don't owe you masculinity")), + (b"And-Stop-Projecting-Your-Dated-Gender-Norms", Cow::Borrowed(b"onto nonbinary people's life experiences")), + ]), + body: body.into_bytes().into(), + }) + } + + /// The method for the [`Route::SendPronounPage`] route + fn send_pronoun_json( + name: Option, + prefs: Option, + conf: &Conf, + ) -> Result { + let settings = &conf.instance_settings; + let pronoun = Route::get_pronoun(name.as_ref(), prefs, settings)?; + + let body = format!(r#" +{{ + "subject_pronoun": "{}", + "object_pronoun": "{}", + "possesive_determiner": "{}", + "possesive_pronoun": "{}", + "reflexive_pronoun": "{}" +}} + "#, + pronoun.subject_pronoun, + pronoun.object_pronoun, + pronoun.possesive_determiner, + pronoun.possesive_pronoun, + pronoun.reflexive_pronoun); + + Ok(Response { + status: b"200", + headers: Cow::Borrowed(&[ + (b"Trans-Women", Cow::Borrowed(b"don't owe you femininity")), + (b"Trans-Men", Cow::Borrowed(b"don't owe you masculinity")), + (b"And-Stop-Projecting-Your-Dated-Gender-Norms", Cow::Borrowed(b"onto nonbinary people's life experiences")), + ]), + 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: b"200", + headers: Cow::Borrowed(&[ + (b"Eat", Cow::Borrowed(b"the rich!")), + ]), + body: data.into(), + }) + } + + /// The method for the [`Route::GenerateLink`] route + fn generate_link( + body: Vec, + settings: &InstanceSettings, + ) -> Result { + let form = form_urlencoded::parse(&body); + + let mut weights = vec![0; settings.pronoun_list.len()]; + let mut name = None; + for (k, v) in form { + if let Ok(i) = k.parse::() { + let w = v.parse::().map_err(|_| BadRequest::BadFormData((*v).into()))?; + if i < weights.len() - 1 { + weights[i] = w; + } + } else if k == "name" { + name = Some(v); + } + } + let prefs = InstanceSettings::create_preferences(&weights); + let pref_string = prefs.as_prefstring(); + let url = match name { + Some(name) if !name.is_empty() => format!( + "/{}/{}", + percent_encode(name.as_bytes(), NON_ALPHANUMERIC), + pref_string + ), + _ => format!("/{}", pref_string), + }; + + Ok(Response { + status: b"303 See Other", + headers: Cow::Owned(vec![ + (&b"Location"[..], url.into_bytes().into()) + ]), + body: Cow::Borrowed(b""), + }) + } + + /// The method for the [`Route::BadRequest`] route + fn bad_request( + error: BadRequest, + _settings: &InstanceSettings + ) -> Response { + match error { + BadRequest::MethodNotAllowed => { + Response { + status: b"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: b"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::BadFormData(yuckers) => { + let body = ErrorPage::from_msg( + &format!( + "yeah that's some stinky form data you got there: {}", + yuckers, + ) + ); + + Response { + status: b"400", + headers: Cow::Borrowed(&[ + (b"Pretty-Gross", Cow::Borrowed(b"imo")), + ]), + body: body.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: b"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: b"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 + /// + /// This is a string of ASCII bytes. It can either be just the number (`b"200"`) or the number + /// and a description (`b"200 Ok"`). It's like this so that into_io_slices works + status: &'static [u8], + + /// 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 { + pub fn into_io_slices(&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 + 5 + 4 * self.headers.len() + ); + + output.extend_from_slice(&[ + IoSlice::new(b"HTTP/1.1 "), + IoSlice::new(self.status), + IoSlice::new(b"\r\n"), + ]); + for (h1, h2) in self.headers.iter() { + output.extend_from_slice(&[ + IoSlice::new(h1), + IoSlice::new(b": "), + IoSlice::new(h2), + IoSlice::new(b"\r\n"), + ]); + } + output.extend_from_slice(&[ + IoSlice::new(b"\r\n"), + IoSlice::new(&*self.body), + ]); + return output; + } } #[derive(Template)] @@ -216,36 +650,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..a72b7d4 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 :( + pub const fn generate_response(&self) -> Response { + Response { + status: b"200", + headers: Cow::Borrowed(StaticAsset::STATIC_HEADERS), + body: Cow::Borrowed(self.bytes), + } } } @@ -66,7 +54,7 @@ macro_rules! static_asset { pub const FONT: StaticAsset = static_asset!("font.otf"); /// A list of static assets which should be served by the server -pub const STATIC_ASSETS: &[StaticAsset] = &[ +pub const STATIC_ASSETS: &[&StaticAsset] = &[ #[cfg(any(feature = "embed_static_assets"))] - FONT, + &FONT, ]; diff --git a/web/src/write_vectored_all.rs b/web/src/write_vectored_all.rs new file mode 100644 index 0000000..e1082c9 --- /dev/null +++ b/web/src/write_vectored_all.rs @@ -0,0 +1,105 @@ +use std::slice; +use std::future::Future; +use std::io::{Error, ErrorKind, IoSlice, Result}; +use std::mem::replace; +use std::pin::Pin; +use std::task::Poll; + +use futures_lite::AsyncWrite; +use futures_lite::ready; + +pub trait AsyncWriteAllVectored: AsyncWrite { + fn write_all_vectored<'a>(&'a mut self, bufs: &'a mut [IoSlice<'a>]) -> WriteAllVectoredFuture<'a, Self> + where + Self: Unpin, + { + WriteAllVectoredFuture { writer: self, bufs } + } +} + +impl AsyncWriteAllVectored for T {} + +pub struct WriteAllVectoredFuture<'a, W: Unpin + ?Sized> { + writer: &'a mut W, + bufs: &'a mut [IoSlice<'a>], +} + +impl Unpin for WriteAllVectoredFuture<'_, W> {} + +impl Future for WriteAllVectoredFuture<'_, W> { + type Output = Result<()>; + + fn poll(mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> Poll { + let Self { writer, bufs } = &mut *self; + + // Guarantee that bufs is empty if it contains no data, + // to avoid calling write_vectored if there is no data to be written. + advance_slices(bufs, 0); + while !bufs.is_empty() { + match ready!(Pin::new(&mut ** writer).poll_write_vectored(cx, bufs)) { + Ok(0) => { + return Poll::Ready(Err(Error::new( + ErrorKind::WriteZero, + "failed to write whole buffer", + ))); + } + Ok(n) => advance_slices(bufs, n), + Err(ref e) if e.kind() == ErrorKind::Interrupted => {} + Err(e) => return Poll::Ready(Err(e)), + } + } + + Poll::Ready(Ok(())) + } +} + +fn advance_slices(bufs: &mut &mut [IoSlice<'_>], n: usize) { + // Number of buffers to remove. + let mut remove = 0; + // Total length of all the to be removed buffers. + let mut accumulated_len = 0; + for buf in bufs.iter() { + if accumulated_len + buf.len() > n { + break; + } else { + accumulated_len += buf.len(); + remove += 1; + } + } + + *bufs = &mut replace(bufs, &mut [])[remove..]; + if !bufs.is_empty() { + advance(&mut bufs[0], n - accumulated_len); + } +} + +fn advance<'a>(buf: &mut IoSlice<'a>, n: usize) { + if buf.len() < n { + panic!("advancing IoSlice beyond its length"); + } + // This is just a hacky way of advancing the pointer inside the IoSlice + // SAFTEY: The newly constructed IoSlice has the same lifetime as the old and + // this is guaranteed not to overflow the buffer due to the previous check + unsafe { + let mut ptr = buf.as_ptr() as *mut u8; + ptr = ptr.add(n); + let len = buf.len() - n; + let new_slice: &'a [u8] = slice::from_raw_parts(ptr, len); + *buf = IoSlice::new(new_slice); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_advance() { + let expected: Vec<_> = (10..100).collect(); + let buf: Vec<_> = (0..100).collect(); + let mut io_slice = IoSlice::new(&buf); + advance(&mut io_slice, 10); + assert_eq!(io_slice.len(), 90); + assert_eq!(&*io_slice, &expected); + } +}