From dd5b4c5238fb8d8d3c5f2645b1406f9e2717384d Mon Sep 17 00:00:00 2001 From: Emi Simpson Date: Sun, 31 Oct 2021 23:04:25 -0400 Subject: [PATCH 01/18] [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), + } } } -- 2.34.1 From 5d47635ce8d8ce771da008c99d30b3eaa52685ce Mon Sep 17 00:00:00 2001 From: Emi Simpson Date: Mon, 1 Nov 2021 18:15:06 -0400 Subject: [PATCH 02/18] [web] [WIP] Restore static asset serving --- web/src/main.rs | 16 ++++++++-------- web/src/statics.rs | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/web/src/main.rs b/web/src/main.rs index 3099108..63c3d53 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -3,6 +3,7 @@ pub mod contrast; pub mod statics; pub mod configuration; +use crate::statics::StaticAsset; use smol::io::AsyncWriteExt; use smol::stream::StreamExt; use std::borrow::Cow; @@ -195,7 +196,11 @@ fn route_request(req: &ScgiRequest) -> Route { #[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 + for asset in statics::STATIC_ASSETS { + if Some(asset.filename) == segments.get(1).map(Deref::deref) { + return Route::SendStatic(asset); + } + } } // Determines if we need to respond with a thumbnail or a web page @@ -258,7 +263,7 @@ enum Route { /// 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]), + SendStatic(&'static StaticAsset), /// Respond with an HTML pronoun page. /// @@ -301,12 +306,7 @@ 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::SendStatic(data) => Ok(data.generate_response()), Route::SendPronounPage(name, prefs) => Route::send_pronoun_page(name, prefs, settings), Route::SendThumbnail(name, prefs) => diff --git a/web/src/statics.rs b/web/src/statics.rs index 39b2ddb..98f4877 100644 --- a/web/src/statics.rs +++ b/web/src/statics.rs @@ -28,7 +28,7 @@ impl StaticAsset { /// 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 { + pub const fn generate_response(&self) -> Response { Response { status: 200, headers: Cow::Borrowed(StaticAsset::STATIC_HEADERS), @@ -54,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, ]; -- 2.34.1 From e55442c47f298474871519c0b87005618eda0ace Mon Sep 17 00:00:00 2001 From: Emi Simpson Date: Mon, 1 Nov 2021 18:28:47 -0400 Subject: [PATCH 03/18] [web] [WIP] Optimize IO slightly --- web/src/main.rs | 39 ++++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/web/src/main.rs b/web/src/main.rs index 63c3d53..73e74af 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -3,6 +3,7 @@ pub mod contrast; pub mod statics; pub mod configuration; +use std::io::IoSlice; use crate::statics::StaticAsset; use smol::io::AsyncWriteExt; use smol::stream::StreamExt; @@ -151,10 +152,10 @@ async fn handle_request( }, }; - if let Err(e) = stream.write_all( + if let Err(e) = stream.write_vectored( route_request(&req) .generate_response(settings) - .into_bytes() + .into_io_slices() .as_ref() ).await { log::warn!( @@ -484,32 +485,32 @@ pub struct Response { } impl Response { - fn into_bytes(self) -> Vec { + 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 - 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() + 5 + 4 * self.headers.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"); + output.extend_from_slice(&[ + IoSlice::new(b"HTTP/1.1 "), + IoSlice::new(self.status.to_string().as_bytes()), + IoSlice::new(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(&[ + IoSlice::new(h1), + IoSlice::new(b": "), + IoSlice::new(h2), + IoSlice::new(b"\r\n"), + ]); } - output.extend_from_slice(b"\r\n"); - output.extend_from_slice(&*self.body); + output.extend_from_slice(&[ + IoSlice::new(b"\r\n"), + IoSlice::new(&*self.body), + ]); return output; } } -- 2.34.1 From b9620e95a8232103cdc83621c4b8b07cab70d11b Mon Sep 17 00:00:00 2001 From: Emi Simpson Date: Mon, 1 Nov 2021 18:39:12 -0400 Subject: [PATCH 04/18] [web] [WIP] Removed old TODO --- web/src/main.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/web/src/main.rs b/web/src/main.rs index 73e74af..057d1bc 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -148,7 +148,6 @@ async fn handle_request( 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 }, }; -- 2.34.1 From 74e41de30978906ad6361987cde989a50fa44f0f Mon Sep 17 00:00:00 2001 From: Emi Simpson Date: Tue, 2 Nov 2021 12:48:13 -0400 Subject: [PATCH 05/18] [web] [WIP] Unbreak the previous two commits --- web/src/main.rs | 43 +++++++++++++++++++++++++++---------------- web/src/statics.rs | 2 +- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/web/src/main.rs b/web/src/main.rs index 057d1bc..3a0a90c 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -148,17 +148,25 @@ async fn handle_request( Err(scgi_error) => { log::error!("Encountered a malformed SCGI request. It could be that your \ webserver is misconfigured. {}", scgi_error); + return }, }; - if let Err(e) = stream.write_vectored( - route_request(&req) - .generate_response(settings) - .into_io_slices() - .as_ref() - ).await { + let response = route_request(&req) + .generate_response(settings); + let io_slices = response.into_io_slices(); + + for slice in io_slices { + if let Err(e) = stream.write_all(&slice).await { + log::warn!( + "Encountered an IO error while sending response: {}", e + ); + break; + } + } + if let Err(e) = stream.close().await { log::warn!( - "Encountered an IO error while sending response: {}", e + "Encountered an IO error while closing connection to server: {}", e ); } } @@ -338,7 +346,7 @@ impl Route { .unwrap(); Ok(Response { - status: 200, + status: b"200", headers: Cow::Borrowed(&[]), body: body.into_bytes().into(), }) @@ -359,7 +367,7 @@ impl Route { .expect("Error encoding thumbnail to PNG"); Ok(Response { - status: 200, + status: b"200", headers: Cow::Borrowed(&[ (b"Eat", Cow::Borrowed(b"the rich!")), ]), @@ -382,7 +390,7 @@ impl Route { match error { BadRequest::MethodNotAllowed => { Response { - status: 405, + status: b"405", headers: Cow::Borrowed(&[ (b"Cache-Control", Cow::Borrowed(b"max-age=999999")), (b"And-Dont", Cow::Borrowed(b"Come Back!")), @@ -392,7 +400,7 @@ impl Route { }, BadRequest::MalformedPrefstr(_) => { Response { - status: 404, + 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!")), @@ -409,7 +417,7 @@ impl Route { ); Response { - status: 400, + 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")), @@ -426,7 +434,7 @@ impl Route { .unwrap(); Response { - status: 400, + 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 :(")), @@ -471,7 +479,10 @@ impl Route { /// 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, + /// + /// 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 /// @@ -484,7 +495,7 @@ pub struct Response { } impl Response { - fn into_io_slices(&self) -> Vec> { + 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. @@ -495,7 +506,7 @@ impl Response { output.extend_from_slice(&[ IoSlice::new(b"HTTP/1.1 "), - IoSlice::new(self.status.to_string().as_bytes()), + IoSlice::new(self.status), IoSlice::new(b"\r\n"), ]); for (h1, h2) in self.headers.iter() { diff --git a/web/src/statics.rs b/web/src/statics.rs index 98f4877..a72b7d4 100644 --- a/web/src/statics.rs +++ b/web/src/statics.rs @@ -30,7 +30,7 @@ impl StaticAsset { // compile-time :( pub const fn generate_response(&self) -> Response { Response { - status: 200, + status: b"200", headers: Cow::Borrowed(StaticAsset::STATIC_HEADERS), body: Cow::Borrowed(self.bytes), } -- 2.34.1 From 56dbe50d2ffc2db2b8ce8ac64332a6f1999a7f07 Mon Sep 17 00:00:00 2001 From: Emi Simpson Date: Tue, 2 Nov 2021 13:36:54 -0400 Subject: [PATCH 06/18] [web] Make sure no one mistakes our software as apolitical --- web/src/main.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/src/main.rs b/web/src/main.rs index 3a0a90c..d1dafa1 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -347,7 +347,11 @@ impl Route { Ok(Response { status: b"200", - headers: Cow::Borrowed(&[]), + 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(), }) } -- 2.34.1 From 2cfe9fcb5fc63a31cceed807af573f511d33c374 Mon Sep 17 00:00:00 2001 From: Emi Simpson Date: Tue, 2 Nov 2021 14:17:47 -0400 Subject: [PATCH 07/18] [web] [WIP] Fix sending OGP tags --- web/assets/default_config.yml | 7 ++++++ web/src/configuration.rs | 10 ++++++++ web/src/main.rs | 47 ++++++++++++++++++++--------------- 3 files changed, 44 insertions(+), 20 deletions(-) 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..721e4fe 100644 --- a/web/src/configuration.rs +++ b/web/src/configuration.rs @@ -82,6 +82,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 { @@ -131,6 +136,9 @@ pub struct Conf { /// The address to bind to. Defaults to 0.0.0.0 pub address: IpAddr, + + /// The base url the server will be running under, with no trailing slash + pub base_url: String, } impl Conf { @@ -138,6 +146,7 @@ impl Conf { self.port = args.port.unwrap_or(self.port); self.address = args.address.unwrap_or(self.address); 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 } } @@ -148,6 +157,7 @@ impl Default for Conf { instance_settings: InstanceSettings::default(), port: 1312, address: IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), + base_url: "https://pronouns.today".into(), } } } diff --git a/web/src/main.rs b/web/src/main.rs index d1dafa1..7e46dba 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -3,6 +3,7 @@ pub mod contrast; pub mod statics; pub mod configuration; +use crate::configuration::Conf; use std::io::IoSlice; use crate::statics::StaticAsset; use smol::io::AsyncWriteExt; @@ -105,13 +106,13 @@ async fn start_server(config: configuration::Conf, executor: &smol::LocalExecuto let connection = smol::net::TcpListener::bind(socket_addr).await?; let mut incoming = connection.incoming(); - // Make the instance settings immortal - let instance_settings = Box::leak(Box::new(config.instance_settings)); + // Make the configuration immortal + let config = Box::leak(Box::new(config)); while let Some(stream) = incoming.next().await { match stream { Ok(stream) => { - executor.spawn(handle_request(stream, instance_settings)).detach(); + executor.spawn(handle_request(stream, config)).detach(); }, Err(e) => { log::error!("IO Error with client/server connection: {}", e); @@ -135,7 +136,7 @@ async fn start_server(config: configuration::Conf, executor: &smol::LocalExecuto /// - 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 + conf: &Conf, ) { let mut stream = smol::io::BufReader::new(raw_stream); @@ -153,7 +154,7 @@ async fn handle_request( }; let response = route_request(&req) - .generate_response(settings); + .generate_response(conf); let io_slices = response.into_io_slices(); for slice in io_slices { @@ -194,9 +195,10 @@ fn route_request(req: &ScgiRequest) -> Route { _ => return Route::BadRequest(BadRequest::MethodNotAllowed), } - let mut segments: Vec<_> = req.headers + let request_uri = req.headers .get("REQUEST_URI") - .expect("Misconfigured server didn't send a REQUEST_URI header") + .expect("Misconfigured server didn't send a REQUEST_URI header"); + let mut segments: Vec<_> = request_uri .split("/") .filter(|s| s.len() > 0) .collect(); @@ -241,13 +243,14 @@ fn route_request(req: &ScgiRequest) -> Route { (None, Some(_)) => unreachable!(), }; - let response_type = if is_thumb { - Route::SendThumbnail - } else { - Route::SendPronounPage - }; + let name = name.map(|n| (*n).to_owned()); - response_type(name.map(|n| (*n).to_owned()), prefs) + if is_thumb { + Route::SendThumbnail(name, prefs) + } else { + let uri = request_uri.trim_end_matches('/').to_owned(); + Route::SendPronounPage(name, prefs, uri) + } } /// Determines the type of route that the request should be met with, and necessary data @@ -276,8 +279,9 @@ enum Route { /// 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), + /// [`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 /// @@ -312,11 +316,12 @@ enum BadRequest { impl Route { /// Actually perform the action that each route entails - fn generate_response(self, settings: &InstanceSettings) -> Response { + 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) => - Route::send_pronoun_page(name, prefs, settings), + Route::SendPronounPage(name, prefs, url) => + Route::send_pronoun_page(name, prefs, url, conf), Route::SendThumbnail(name, prefs) => Route::send_thumbnail(name, prefs, settings), Route::GenerateLink => @@ -332,15 +337,17 @@ impl Route { fn send_pronoun_page( name: Option, prefs: Option, - settings: &InstanceSettings, + uri: String, + conf: &Conf, ) -> Result { + let settings = &conf.instance_settings; 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 + url: format!("{}{}", &conf.base_url, uri), //TODO } .render() .unwrap(); -- 2.34.1 From 7c07aac2af5ede373d33c81331394aaf1865dbe5 Mon Sep 17 00:00:00 2001 From: Emi Simpson Date: Tue, 2 Nov 2021 14:26:36 -0400 Subject: [PATCH 08/18] [web] [WIP] Remove trailing TODO --- web/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/main.rs b/web/src/main.rs index 7e46dba..ac5d451 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -347,7 +347,7 @@ impl Route { name, pronoun, pronouns: settings.pronoun_list.iter().enumerate().collect(), - url: format!("{}{}", &conf.base_url, uri), //TODO + url: format!("{}{}", &conf.base_url, uri), } .render() .unwrap(); -- 2.34.1 From 26b4b20c1d3dc12e6ba49f858d3078fda5c3d5ba Mon Sep 17 00:00:00 2001 From: Emi Simpson Date: Tue, 2 Nov 2021 17:19:53 -0400 Subject: [PATCH 09/18] [web] [WIP] Re-enable url generation --- web/src/main.rs | 88 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 59 insertions(+), 29 deletions(-) diff --git a/web/src/main.rs b/web/src/main.rs index ac5d451..7a8e4ea 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -3,6 +3,7 @@ pub mod contrast; pub mod statics; pub mod configuration; +use std::collections::HashMap; use crate::configuration::Conf; use std::io::IoSlice; use crate::statics::StaticAsset; @@ -29,29 +30,6 @@ use image::{DynamicImage, ImageOutputFormat}; #[cfg(feature = "ogp_images")] use ogp_images::render_today; -/* Ill deal with this later -async fn create_link( - 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::() { - 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), - }; - url -}*/ - const NOT_FOUND: &[u8] = include_bytes!("../templates/404.html"); /// Handles initialization, and runs top-level routing based on cli args @@ -189,7 +167,7 @@ fn route_request(req: &ScgiRequest) -> Route { "GET" => (), // All POST requests are met with link generation - "POST" => return Route::GenerateLink, + "POST" => return Route::GenerateLink(String::from_utf8_lossy(&req.body).into_owned()), // No other methods are supported _ => return Route::BadRequest(BadRequest::MethodNotAllowed), @@ -289,7 +267,7 @@ enum Route { SendThumbnail(Option, Option), /// Generate a link to the appropriate pronoun page based on the user's inputs. - GenerateLink, //TODO + GenerateLink(String), /// There was a problem with the user's request, and an error page should be sent BadRequest(BadRequest), @@ -306,6 +284,9 @@ enum BadRequest { /// 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`] @@ -324,8 +305,8 @@ impl Route { Route::send_pronoun_page(name, prefs, url, conf), Route::SendThumbnail(name, prefs) => Route::send_thumbnail(name, prefs, settings), - Route::GenerateLink => - Route::generate_link(settings), + Route::GenerateLink(body) => + Route::generate_link(body, settings), Route::BadRequest(error) => Ok(Route::bad_request(error, settings)), }; @@ -388,9 +369,42 @@ impl Route { /// The method for the [`Route::GenerateLink`] route fn generate_link( - _settings: &InstanceSettings, + body: String, + settings: &InstanceSettings, ) -> Result { - todo!() + let form = body.split('&') + .filter_map(|entry| { + let mut split = entry.split('='); + split.next().map(|key| ( + key, + split.next().unwrap_or("") + )) + }) + .collect::>(); + + 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(|_| BadRequest::BadFormData((*v).into()))?; + 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(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 @@ -419,6 +433,22 @@ impl Route { 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 \ -- 2.34.1 From 2bfdf0ef4923dc767088e98a62163e67f15ec092 Mon Sep 17 00:00:00 2001 From: Emi Simpson Date: Tue, 2 Nov 2021 19:08:50 -0400 Subject: [PATCH 10/18] Improve URL encoding and decoding --- web/Cargo.toml | 2 ++ web/src/main.rs | 40 ++++++++++++++++++++++------------------ 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/web/Cargo.toml b/web/Cargo.toml index 6391345..f9acfcb 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -15,6 +15,8 @@ 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/src/main.rs b/web/src/main.rs index 7a8e4ea..349a778 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -3,13 +3,14 @@ pub mod contrast; pub mod statics; pub mod configuration; -use std::collections::HashMap; use crate::configuration::Conf; use std::io::IoSlice; use crate::statics::StaticAsset; use smol::io::AsyncWriteExt; use smol::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; @@ -167,7 +168,7 @@ fn route_request(req: &ScgiRequest) -> Route { "GET" => (), // All POST requests are met with link generation - "POST" => return Route::GenerateLink(String::from_utf8_lossy(&req.body).into_owned()), + "POST" => return Route::GenerateLink(req.body.clone()), // No other methods are supported _ => return Route::BadRequest(BadRequest::MethodNotAllowed), @@ -267,7 +268,7 @@ enum Route { SendThumbnail(Option, Option), /// Generate a link to the appropriate pronoun page based on the user's inputs. - GenerateLink(String), + GenerateLink(Vec), /// There was a problem with the user's request, and an error page should be sent BadRequest(BadRequest), @@ -325,7 +326,11 @@ impl Route { let pronoun = Route::get_pronoun(name.as_ref(), prefs, settings)?; let body = IndexTemplate { - name, + 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), @@ -369,32 +374,31 @@ impl Route { /// The method for the [`Route::GenerateLink`] route fn generate_link( - body: String, + body: Vec, settings: &InstanceSettings, ) -> Result { - let form = body.split('&') - .filter_map(|entry| { - let mut split = entry.split('='); - split.next().map(|key| ( - key, - split.next().unwrap_or("") - )) - }) - .collect::>(); + let form = form_urlencoded::parse(&body); let mut weights = vec![0; settings.pronoun_list.len()]; - for (k, v) in form.iter() { + 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 form.get("name") { - Some(name) if !name.is_empty() => format!("/{}/{}", name, pref_string), + let url = match name { + Some(name) if !name.is_empty() => format!( + "/{}/{}", + percent_encode(name.as_bytes(), NON_ALPHANUMERIC), + pref_string + ), _ => format!("/{}", pref_string), }; -- 2.34.1 From 4ed748cf1e8b323db0ea96e9efdc7348f3f75d5d Mon Sep 17 00:00:00 2001 From: Emi Simpson Date: Wed, 3 Nov 2021 10:44:39 -0400 Subject: [PATCH 11/18] [web] [WIP] Use smol's constituent crates instead of all of smol --- web/Cargo.toml | 4 +++- web/src/main.rs | 19 ++++++++++--------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/web/Cargo.toml b/web/Cargo.toml index f9acfcb..09c74f2 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -8,7 +8,9 @@ edition = "2021" [dependencies] pronouns_today = {path = ".."} async-scgi = "0.1.0" -smol = "1.2" +async-executor = "1.4" +async-net = "1.6" +futures-lite = "1.12" log = "0.4" env_logger = "0.9" askama = "0.10" diff --git a/web/src/main.rs b/web/src/main.rs index 349a778..5e4bdeb 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -6,8 +6,10 @@ pub mod configuration; use crate::configuration::Conf; use std::io::IoSlice; use crate::statics::StaticAsset; -use smol::io::AsyncWriteExt; -use smol::stream::StreamExt; +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}; @@ -24,7 +26,6 @@ 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}; @@ -63,9 +64,9 @@ fn main() { } }; - let executor = smol::LocalExecutor::new(); + let executor = LocalExecutor::new(); log::info!("Starting with configuration {:?}", config); - smol::block_on(executor.run(start_server(config, &executor))).unwrap(); + futures_lite::future::block_on(executor.run(start_server(config, &executor))).unwrap(); } } } @@ -76,13 +77,13 @@ fn main() { /// 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<()> { +async fn start_server(config: configuration::Conf, executor: &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); - let connection = smol::net::TcpListener::bind(socket_addr).await?; + let connection = TcpListener::bind(socket_addr).await?; let mut incoming = connection.incoming(); // Make the configuration immortal @@ -114,10 +115,10 @@ async fn start_server(config: configuration::Conf, executor: &smol::LocalExecuto /// - 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, + raw_stream: impl futures_lite::AsyncRead + futures_lite::AsyncWrite + Unpin, conf: &Conf, ) { - let mut stream = smol::io::BufReader::new(raw_stream); + let mut stream = futures_lite::io::BufReader::new(raw_stream); let req = match async_scgi::read_request(&mut stream).await { Ok(req) => req, -- 2.34.1 From 63cfa014d591cf97eeb6b04a27ff001daf17d455 Mon Sep 17 00:00:00 2001 From: Ben Aaron Goldberg Date: Wed, 3 Nov 2021 19:43:46 -0400 Subject: [PATCH 12/18] [web] use actual vectored write Since IoSlice::advance isn't stable I had to hack my own. I will need to confirm that my implementation is safe. Signed-off-by: Ben Aaron Goldberg --- web/src/main.rs | 15 +++--- web/src/write_vectored_all.rs | 89 +++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 9 deletions(-) create mode 100644 web/src/write_vectored_all.rs diff --git a/web/src/main.rs b/web/src/main.rs index 5e4bdeb..305d506 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -2,6 +2,7 @@ pub mod ogp_images; pub mod contrast; pub mod statics; pub mod configuration; +mod write_vectored_all; use crate::configuration::Conf; use std::io::IoSlice; @@ -16,6 +17,7 @@ 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; @@ -135,16 +137,11 @@ async fn handle_request( let response = route_request(&req) .generate_response(conf); - let io_slices = response.into_io_slices(); + let mut io_slices = response.into_io_slices(); - for slice in io_slices { - if let Err(e) = stream.write_all(&slice).await { - log::warn!( - "Encountered an IO error while sending response: {}", e - ); - break; - } - } + 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 diff --git a/web/src/write_vectored_all.rs b/web/src/write_vectored_all.rs new file mode 100644 index 0000000..cc153f6 --- /dev/null +++ b/web/src/write_vectored_all.rs @@ -0,0 +1,89 @@ +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"); + } + // SAFTEY: hopefully + 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); + } +} + -- 2.34.1 From 3a4afbcaea2270c49f9377063d27de60e0f24713 Mon Sep 17 00:00:00 2001 From: Ben Aaron Goldberg Date: Wed, 3 Nov 2021 20:30:30 -0400 Subject: [PATCH 13/18] [web] confirm safety of advance After further testing and checks I'm fairy confident that the vectored write code is safe. Signed-off-by: Ben Aaron Goldberg --- web/src/write_vectored_all.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/web/src/write_vectored_all.rs b/web/src/write_vectored_all.rs index cc153f6..e1082c9 100644 --- a/web/src/write_vectored_all.rs +++ b/web/src/write_vectored_all.rs @@ -77,7 +77,9 @@ fn advance<'a>(buf: &mut IoSlice<'a>, n: usize) { if buf.len() < n { panic!("advancing IoSlice beyond its length"); } - // SAFTEY: hopefully + // 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); @@ -87,3 +89,17 @@ fn advance<'a>(buf: &mut IoSlice<'a>, n: usize) { } } +#[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); + } +} -- 2.34.1 From adcbae08ca4516562e78e99edcb386ed0081cd4b Mon Sep 17 00:00:00 2001 From: Emi Simpson Date: Fri, 5 Nov 2021 16:24:31 -0400 Subject: [PATCH 14/18] Allow binding to unix sockets --- web/src/configuration.rs | 21 +++++++---------- web/src/main.rs | 49 ++++++++++++++++++++++++++++------------ 2 files changed, 43 insertions(+), 27 deletions(-) diff --git a/web/src/configuration.rs b/web/src/configuration.rs index 721e4fe..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 @@ -134,8 +129,9 @@ 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, @@ -143,8 +139,7 @@ pub struct Conf { 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 @@ -156,7 +151,7 @@ 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 305d506..39259c8 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -4,6 +4,7 @@ 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; @@ -80,24 +81,44 @@ fn main() { /// 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<()> { - // Where we binding bois - let socket_addr = SocketAddr::new(config.address, config.port); - - println!("Starting pronouns-today-web on {}", &socket_addr); - - let connection = TcpListener::bind(socket_addr).await?; - let mut incoming = connection.incoming(); // Make the configuration immortal let config = Box::leak(Box::new(config)); - 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); + // Where we binding bois + if let Ok(socket_addr) = config.bind.parse() as Result { + + println!("Starting pronouns-today-web on {}", &socket_addr); + + let connection = TcpListener::bind(socket_addr).await?; + let mut incoming = connection.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); + } + } + } + + } 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); + } } } } -- 2.34.1 From 57e1b5c85bc78df229f1bdd5175f3b6b960b6e15 Mon Sep 17 00:00:00 2001 From: Emi Simpson Date: Sun, 7 Nov 2021 12:29:02 -0500 Subject: [PATCH 15/18] [web] Use the asyncio block_on --- web/Cargo.toml | 1 + web/src/main.rs | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/web/Cargo.toml b/web/Cargo.toml index 09c74f2..f7e0c50 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -10,6 +10,7 @@ pronouns_today = {path = ".."} 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" diff --git a/web/src/main.rs b/web/src/main.rs index 39259c8..1c3d825 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -8,6 +8,7 @@ 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; @@ -69,7 +70,7 @@ fn main() { let executor = LocalExecutor::new(); log::info!("Starting with configuration {:?}", config); - futures_lite::future::block_on(executor.run(start_server(config, &executor))).unwrap(); + block_on(executor.run(start_server(config, &executor))).unwrap(); } } } -- 2.34.1 From 4cef709f526641d0a20ffe9963ee7917dca7d71e Mon Sep 17 00:00:00 2001 From: Sashanoraa Date: Mon, 15 Nov 2021 01:41:49 -0500 Subject: [PATCH 16/18] REQUEST_URL debug log message Signed-off-by: Sashanoraa --- web/src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/main.rs b/web/src/main.rs index 1c3d825..0fff4a3 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -197,6 +197,7 @@ fn route_request(req: &ScgiRequest) -> Route { 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) -- 2.34.1 From 17acac814b767247348b46eb99667b4d703a2d48 Mon Sep 17 00:00:00 2001 From: Ben Aaron Goldberg Date: Sat, 13 Nov 2021 18:04:40 -0500 Subject: [PATCH 17/18] WIP [web] Add a JSON pronouns endpoint Signed-off-by: Ben Aaron Goldberg --- web/src/main.rs | 71 ++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 62 insertions(+), 9 deletions(-) diff --git a/web/src/main.rs b/web/src/main.rs index 0fff4a3..a860ecc 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -213,11 +213,20 @@ fn route_request(req: &ScgiRequest) -> Route { } } + #[derive(PartialEq, Eq)] + enum RouteType { + Html, + Thumbnail, + Json, + } + // 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 { + 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(); } @@ -245,11 +254,13 @@ fn route_request(req: &ScgiRequest) -> Route { let name = name.map(|n| (*n).to_owned()); - if is_thumb { - Route::SendThumbnail(name, prefs) - } else { - let uri = request_uri.trim_end_matches('/').to_owned(); - Route::SendPronounPage(name, prefs, uri) + 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), } } @@ -288,6 +299,11 @@ enum Route { /// 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), @@ -325,6 +341,8 @@ impl Route { 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) => @@ -370,6 +388,41 @@ impl Route { }) } + /// 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( -- 2.34.1 From 5ff817e902728efcf83a307035981edbd6eda146 Mon Sep 17 00:00:00 2001 From: Sashanoraa Date: Mon, 15 Nov 2021 01:45:31 -0500 Subject: [PATCH 18/18] Fix json indentation Signed-off-by: Sashanoraa --- web/src/main.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/web/src/main.rs b/web/src/main.rs index a860ecc..9b69827 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -398,13 +398,13 @@ impl Route { let pronoun = Route::get_pronoun(name.as_ref(), prefs, settings)?; let body = format!(r#" - {{ - "subject_pronoun": "{}", - "object_pronoun": "{}", - "possesive_determiner": "{}", - "possesive_pronoun": "{}", - "reflexive_pronoun": "{}" - }} +{{ + "subject_pronoun": "{}", + "object_pronoun": "{}", + "possesive_determiner": "{}", + "possesive_pronoun": "{}", + "reflexive_pronoun": "{}" +}} "#, pronoun.subject_pronoun, pronoun.object_pronoun, -- 2.34.1