PronounsToday/web/src/main.rs

619 lines
19 KiB
Rust

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::ops::Deref;
use argh;
use askama::Template;
use async_scgi::{ScgiReadError, ScgiRequest};
use pronouns_today::user_preferences::Preference;
use pronouns_today::{InstanceSettings, Pronoun};
#[cfg(feature = "ogp_images")]
use image::{DynamicImage, ImageOutputFormat};
#[cfg(feature = "ogp_images")]
use ogp_images::render_today;
const NOT_FOUND: &[u8] = include_bytes!("../templates/404.html");
/// Handles initialization, and runs top-level routing based on cli args
fn main() {
env_logger::init();
let args: configuration::PronounsTodayArgs = argh::from_env();
match args.command {
configuration::SubCommand::DumpStatics(_subargs) => {
println!("Support for dumping statics not yet implemented");
return;
}
configuration::SubCommand::Run(subargs) => {
let config = match subargs.load_config() {
Ok(config) => config,
Err(ConfigError::IoError(e)) => {
eprintln!("IO Error while reading config! {}", e);
exit(1000);
}
Err(ConfigError::MalformedConfig(e)) => {
eprintln!("Error parsing config file:\n{}", e);
exit(1001);
}
Err(ConfigError::ConfigCreated(path)) => {
println!("A config file has been generated at {}! Please check it out \
and modify it to your liking, and then run this command \
again", path.display());
exit(1002);
}
};
let executor = LocalExecutor::new();
log::info!("Starting with configuration {:?}", config);
block_on(executor.run(start_server(config, &executor))).unwrap();
}
}
}
/// 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
if let Ok(socket_addr) = config.bind.parse() as Result<SocketAddr, _> {
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);
}
}
}
}
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);
}
}
}
// 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<UserPreferences, _> = 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());
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
///
/// 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<String>, Option<UserPreferences>, String),
/// Respond with an image containing the user's pronouns
///
/// Takes the user's name (optional), and the preferences of that user
SendThumbnail(Option<String>, Option<UserPreferences>),
/// Generate a link to the appropriate pronoun page based on the user's inputs.
GenerateLink(Vec<u8>),
/// 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::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<String>,
prefs: Option<UserPreferences>,
uri: String,
conf: &Conf,
) -> Result<Response, BadRequest> {
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(),
})
}
#[cfg(feature = "ogp_images")]
/// The method for the [`Route::SendThumbnail`] route
fn send_thumbnail(
name: Option<String>,
prefs: Option<UserPreferences>,
settings: &InstanceSettings,
) -> Result<Response, BadRequest> {
let pronoun = Route::get_pronoun(name.as_ref(), prefs, settings)?;
let mut data: Vec<u8> = 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<u8>,
settings: &InstanceSettings,
) -> Result<Response, BadRequest> {
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::<usize>() {
let w = v.parse::<u8>().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<UserPreferences>,
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<IoSlice<'_>> {
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)]
#[template(path = "error.html")]
struct ErrorPage {
msg: String,
}
impl ErrorPage {
fn from_msg(msg: &str) -> Vec<u8> {
ErrorPage {
msg: msg.into()
}
.render()
.unwrap()
.into_bytes()
}
}
#[derive(Template)]
#[template(path = "index.html")]
struct IndexTemplate<'a> {
name: Option<String>,
pronoun: &'a Pronoun,
pronouns: Vec<(usize, &'a Pronoun)>,
url: String,
}