Migrate to actix-web #8

Manually merged
tlater merged 14 commits from tlater/actix-web into master 2022-09-16 17:44:53 +01:00
12 changed files with 933 additions and 2451 deletions

4
.dir-locals.el Normal file
View file

@ -0,0 +1,4 @@
((nil . ((indent-tabs-mode . nil)
(tab-width . 4)
(fill-column . 80)
(projectile-project-run-cmd . "cargo run -- --dev-mode --template-directory ~/Documents/Projects/tlaternet-templates/result"))))

2738
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,15 @@
[package] [package]
name = "tlaternet-webserver" name = "tlaternet-webserver"
version = "0.1.0" version = "0.1.0"
authors = ["Tristan Daniël Maat <tm@tlater.net>"] edition = "2021"
edition = "2018"
[dependencies] [dependencies]
check-if-email-exists = "0.8.5" actix-files = "0.6.2"
lettre = "0.9.3" actix-web = { version = "4.2.1", features = ["macros"] }
lettre_email = "0.9.3" clap = { version = "3.2.17", features = ["derive"] }
rocket = "0.4.4" derive_more = "0.99.17"
rocket_contrib = { version = "0.4.4", features = [ "handlebars_templates", "serve" ] } env_logger = "0.9.0"
serde = { version = "1.0.111", features = [ "derive" ] } handlebars = { version = "4.3.3", features = ["dir_source"] }
log = "0.4.17"
serde = {version = "1.0.144", features = ["derive"]}
serde_json = "1.0.83"

View file

@ -15,21 +15,6 @@
"type": "github" "type": "github"
} }
}, },
"flake-utils_2": {
"locked": {
"lastModified": 1631561581,
"narHash": "sha256-3VQMV5zvxaVLvqqUrNz3iJelLw30mIVSfZmAaauM3dA=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "7e5bf3925f6fbdfaf50a2a7ca0be2879c4261d19",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"naersk": { "naersk": {
"inputs": { "inputs": {
"nixpkgs": [ "nixpkgs": [
@ -37,11 +22,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1659610603, "lastModified": 1662220400,
"narHash": "sha256-LYgASYSPYo7O71WfeUOaEUzYfzuXm8c8eavJcel+pfI=", "narHash": "sha256-9o2OGQqu4xyLZP9K6kNe1pTHnyPz0Wr3raGYnr9AIgY=",
"owner": "nmattia", "owner": "nmattia",
"repo": "naersk", "repo": "naersk",
"rev": "c6a45e4277fa58abd524681466d3450f896dc094", "rev": "6944160c19cb591eb85bbf9b2f2768a935623ed3",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -50,28 +35,38 @@
"type": "github" "type": "github"
} }
}, },
"naersk_2": { "nix-filter": {
"inputs": {
"nixpkgs": [
"tlaternet-templates",
"nixpkgs"
]
},
"locked": { "locked": {
"lastModified": 1632266297, "lastModified": 1661201956,
"narHash": "sha256-J1yeJk6Gud9ef2pEf6aKQemrfg1pVngYDSh+SAY94xk=", "narHash": "sha256-RizGJH/buaw9A2+fiBf9WnXYw4LZABB5kMAZIEE5/T8=",
"owner": "nmattia", "owner": "numtide",
"repo": "naersk", "repo": "nix-filter",
"rev": "ee7edec50b49ab6d69b06d62f1de554efccb1ccd", "rev": "3b821578685d661a10b563cba30b1861eec05748",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "nmattia", "owner": "numtide",
"repo": "naersk", "repo": "nix-filter",
"type": "github" "type": "github"
} }
}, },
"nixpkgs": { "nixpkgs": {
"locked": {
"lastModified": 1663244735,
"narHash": "sha256-+EukKkeAx6ithOLM1u5x4D12ZFuoi6vpPYjhNDmLz1o=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "178fea1414ae708a5704490f4c49ec3320be9815",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-22.05",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1660318005, "lastModified": 1660318005,
"narHash": "sha256-g9WCa9lVUmOV6dYRbEPjv/TLOR5hamjeCcKExVGS3OQ=", "narHash": "sha256-g9WCa9lVUmOV6dYRbEPjv/TLOR5hamjeCcKExVGS3OQ=",
@ -87,19 +82,19 @@
"type": "github" "type": "github"
} }
}, },
"nixpkgs_2": { "npmlock2nix": {
"flake": false,
"locked": { "locked": {
"lastModified": 1632408697, "lastModified": 1654775747,
"narHash": "sha256-JqTfu361AwFmV0WszXLAjfukqGxBbHRopRgdp9A2w8s=", "narHash": "sha256-9pXHDpIjmsK5390wmpGHu9aA4QOPpegPBvThHeBlef4=",
"owner": "nixos", "owner": "nix-community",
"repo": "nixpkgs", "repo": "npmlock2nix",
"rev": "a13824fe5e38187fbc75cd598b5c06bdcc13501f", "rev": "5c4f247688fc91d665df65f71c81e0726621aaa8",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "nixos", "owner": "nix-community",
"ref": "nixos-20.09", "repo": "npmlock2nix",
"repo": "nixpkgs",
"type": "github" "type": "github"
} }
}, },
@ -119,36 +114,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1660358625, "lastModified": 1663297375,
"narHash": "sha256-uv+ZtOAEeM5tw78CLdRQmbZyDZYc0piSflthG2kNnrc=", "narHash": "sha256-7pjd2x9fSXXynIzp9XiXjbYys7sR6MKCot/jfGL7dgE=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "18354cce8137aaef0d505d6f677e9bbdd542020d", "rev": "0678b6187a153eb0baa9688335b002fe14ba6712",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"rust-overlay_2": {
"inputs": {
"flake-utils": [
"tlaternet-templates",
"flake-utils"
],
"nixpkgs": [
"tlaternet-templates",
"nixpkgs"
]
},
"locked": {
"lastModified": 1633400100,
"narHash": "sha256-kHQV7jZ2vVHVI9sfda1mUROVBbQbdfKcbIpKG9WdqGo=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "9c2fc6a62ccbc6f420d71ecac6bf0b84dbbee64f",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -159,23 +129,22 @@
}, },
"tlaternet-templates": { "tlaternet-templates": {
"inputs": { "inputs": {
"flake-utils": "flake-utils_2", "nix-filter": "nix-filter",
"naersk": "naersk_2",
"nixpkgs": "nixpkgs_2", "nixpkgs": "nixpkgs_2",
"rust-overlay": "rust-overlay_2" "npmlock2nix": "npmlock2nix"
}, },
"locked": { "locked": {
"lastModified": 1633433130, "lastModified": 1663345814,
"narHash": "sha256-jkW+HV8cJvE86gppOEXQl2ke+bDHJ7SAp8eJp8pJ0N8=", "narHash": "sha256-wIl8P+Hv8zHwBATlEoppPNJMpcR2EiQ4dbkgGXszmf8=",
"ref": "master", "ref": "master",
"rev": "1232950c06ae16bf17fb16ac1f5f2231e971936b", "rev": "789431c13cf1e906cbaf48e9b1078056c8ec3cc8",
"revCount": 16, "revCount": 111,
"type": "git", "type": "git",
"url": "https://gitea.tlater.net/tlaternet/tlaternet.git" "url": "https://gitea.tlater.net/tlaternet/tlaternet-templates.git"
}, },
"original": { "original": {
"type": "git", "type": "git",
"url": "https://gitea.tlater.net/tlaternet/tlaternet.git" "url": "https://gitea.tlater.net/tlaternet/tlaternet-templates.git"
} }
} }
}, },

View file

@ -13,7 +13,7 @@
}; };
tlaternet-templates = { tlaternet-templates = {
url = "git+https://gitea.tlater.net/tlaternet/tlaternet.git"; url = "git+https://gitea.tlater.net/tlaternet/tlaternet-templates.git";
# No need to override anything here; we can save some downloads # No need to override anything here; we can save some downloads
# if we rely on the webserver to do that. # if we rely on the webserver to do that.
}; };
@ -57,8 +57,8 @@
apps.${system} = { apps.${system} = {
run-with-templates = let run-with-templates = let
script = pkgs.writeShellScriptBin "run-with-templates" '' script = pkgs.writeShellScriptBin "run-with-templates" ''
export ROCKET_TEMPLATE_DIR=${tlaternet-templates.packages.${system}.tlaternet-templates} RUST_LOG=info ${self.packages.${system}.tlaternet-webserver}/bin/tlaternet-webserver \
${self.packages.${system}.tlaternet-webserver}/bin/tlaternet-webserver --template-directory ${tlaternet-templates.packages.${system}.default}
''; '';
in { in {
type = "app"; type = "app";

View file

@ -1,38 +0,0 @@
use rocket::request::FlashMessage;
use serde::Serialize;
#[derive(Default, Serialize)]
pub(crate) struct Context {
pub flash: Option<Flash>,
}
#[allow(dead_code)] // Want to keep all possible flash types
#[derive(Serialize)]
#[serde(rename_all = "lowercase", tag = "type", content = "message")]
pub(crate) enum Flash {
Primary(String),
Secondary(String),
Success(String),
Danger(String),
Warning(String),
Info(String),
Light(String),
Dark(String),
}
impl From<FlashMessage<'_, '_>> for Flash {
fn from(message: FlashMessage) -> Self {
match message.name() {
"success" => Self::Success(message.msg().to_string()),
"warning" => Self::Warning(message.msg().to_string()),
"error" => Self::Danger(message.msg().to_string()),
"primary" => Self::Primary(message.msg().to_string()),
"secondary" => Self::Secondary(message.msg().to_string()),
"info" => Self::Info(message.msg().to_string()),
"light" => Self::Light(message.msg().to_string()),
"dark" => Self::Dark(message.msg().to_string()),
name => Self::Info(format!("{}: {}", name, message.msg())),
}
}
}

72
src/errors.rs Normal file
View file

@ -0,0 +1,72 @@
use actix_web::body::BoxBody;
use actix_web::dev::ServiceResponse;
use actix_web::http::header::ContentType;
use actix_web::http::StatusCode;
use actix_web::middleware::ErrorHandlerResponse;
use actix_web::{web, HttpResponse, ResponseError};
use derive_more::{Display, Error};
use super::SharedData;
use crate::template_utils::{render_template, ErrorMessage, TemplateArgs};
#[derive(Debug, Display, Error)]
pub enum UserError {
NotFound,
#[display(fmt = "Internal error. Try again later.")]
InternalError,
}
impl ResponseError for UserError {
fn error_response(&self) -> HttpResponse {
HttpResponse::build(self.status_code())
.insert_header(ContentType::html())
.body(self.to_string())
}
fn status_code(&self) -> StatusCode {
match *self {
UserError::NotFound => StatusCode::NOT_FOUND,
UserError::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
pub fn generic_error<B>(
res: ServiceResponse<B>,
) -> actix_web::Result<ErrorHandlerResponse<BoxBody>> {
let data = res
.request()
.app_data::<web::Data<SharedData>>()
.map(|t| t.get_ref());
let status_code = res.response().status();
let message = if let Some(error) = status_code.canonical_reason() {
error
} else {
""
};
let response = match data {
Some(SharedData {
handlebars,
config: _,
}) => {
let args = TemplateArgs::builder()
.error_page(ErrorMessage::new(message, status_code.as_u16()))
.build();
let body = render_template(handlebars, "error", &args)
.map_err(|_| UserError::InternalError)?;
HttpResponse::build(res.status())
.content_type(ContentType::html())
.body(body)
}
None => Err(UserError::InternalError)?,
};
Ok(ErrorHandlerResponse::Response(ServiceResponse::new(
res.into_parts().0,
response.map_into_left_body(),
)))
}

View file

@ -1,50 +0,0 @@
use std::fs::create_dir_all;
use lettre::{FileTransport, Transport};
use lettre_email::EmailBuilder;
use rocket::request::{Form, FromForm};
use rocket::response::{Flash, Redirect};
use rocket::{post, routes, Route};
#[derive(FromForm)]
struct Email {
mail: String,
subject: String,
message: String,
}
fn send_mail(email: &Email) -> Result<(), String> {
let email = EmailBuilder::new()
.to("tm@tlater.net")
.from(email.mail.clone())
.subject(email.subject.clone())
.text(email.message.clone())
.build()
.map_err(|err| format!("Invalid email contents: {}", err))?;
let mut mailer = FileTransport::new("/srv/mail");
if let Err(error) = create_dir_all("/srv/mail") {
println!("Could not create mail directory: {}", error);
};
mailer.send(email.into()).map_err(|err| {
println!("Could not save mail: {}", err);
"Failed to send email due to internal issues; please try again later".to_string()
})?;
Ok(())
}
#[post("/mail.html", data = "<email>")]
fn mail_post(email: Form<Email>) -> Result<Flash<Redirect>, Flash<Redirect>> {
match send_mail(&email) {
Ok(_) => Ok(Flash::success(
Redirect::to("/mail.html"),
"Email sent successfully",
)),
Err(err) => Err(Flash::error(Redirect::to("/mail.html"), err)),
}
}
pub fn routes() -> Vec<Route> {
routes![mail_post]
}

View file

@ -1,34 +1,81 @@
#![feature(proc_macro_hygiene, decl_macro)] #![allow(dead_code)]
use std::net::SocketAddr;
use std::path::PathBuf;
use rocket::fairing::AdHoc; use actix_files::Files;
use actix_web::{
use rocket_contrib::serve::StaticFiles; http::{Method, StatusCode},
use rocket_contrib::templates::Template; middleware::{self, ErrorHandlers},
web, App, HttpServer,
mod context;
mod mail;
mod static_templates;
use mail::routes as mail_routes;
use static_templates::routes as static_templates;
fn main() {
rocket::ignite()
.attach(Template::fairing())
.mount("/", mail_routes())
.mount("/", static_templates())
.attach(AdHoc::on_attach("Static files config", |rocket| {
let static_path = match rocket.config().get_string("template_dir") {
Ok(dir) => dir,
Err(rocket::config::ConfigError::Missing { .. }) => "templates".to_string(),
Err(err) => {
eprintln!("Error reading configuration: {}", err);
eprintln!("Using default templates path.");
"templates".to_string()
},
}; };
use clap::Parser;
use handlebars::Handlebars;
Ok(rocket.mount("/", StaticFiles::from(static_path))) mod errors;
})) mod main_pages;
.launch(); mod template_utils;
use errors::generic_error;
use main_pages::{mail_post, template};
#[derive(Parser, Debug, Clone)]
struct Config {
#[clap(long, value_parser)]
/// The directory from which to serve static content and
/// handlebars templates
template_directory: PathBuf,
#[clap(long, default_value = "127.0.0.1:8000", value_parser)]
/// The address on which to listen
address: SocketAddr,
#[clap(long, action)]
/// Whether to start the server in dev mode; this enables some
/// nice handlebars features that are not intended for production
dev_mode: bool,
}
#[derive(Debug)]
struct SharedData<'a> {
handlebars: Handlebars<'a>,
config: Config,
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let mut config = Config::parse();
config.template_directory = config.template_directory.canonicalize()?;
env_logger::init();
let mut handlebars = Handlebars::new();
handlebars
.register_templates_directory(".html", config.template_directory.clone())
.expect("templates should compile correctly");
handlebars.set_dev_mode(config.dev_mode);
let shared_data = web::Data::new(SharedData {
handlebars,
config: config.clone(),
});
HttpServer::new(move || {
App::new()
.wrap(middleware::NormalizePath::trim())
// TODO(tlater): When actix-web 4.3 releases, this can be improved a
// lot because of this PR:
//
// https://github.com/actix/actix-web/pull/2784
.wrap(
ErrorHandlers::new()
.handler(StatusCode::NOT_FOUND, generic_error)
.handler(StatusCode::INTERNAL_SERVER_ERROR, generic_error),
)
.app_data(shared_data.clone())
.service(template)
.service(mail_post)
.service(Files::new("/", &config.template_directory))
.default_service(web::route().method(Method::GET))
})
.bind(config.address)?
.run()
.await
} }

45
src/main_pages.rs Normal file
View file

@ -0,0 +1,45 @@
use actix_web::{post, routes, web, HttpRequest, HttpResponse, Responder};
use log::info;
use serde::Deserialize;
use crate::template_utils::{render_template, Flash, FlashType, TemplateArgs};
use crate::SharedData;
#[derive(Clone, Debug, Deserialize)]
pub(crate) struct Mail {
mail: String,
subject: String,
message: String,
}
#[routes]
#[get(r"/")]
#[get(r"/{filename:.*\.html}")]
pub(crate) async fn template(
shared: web::Data<SharedData<'_>>,
req: HttpRequest,
) -> actix_web::Result<impl Responder> {
let path = match req.match_info().query("filename") {
"" => "index",
other => other
.strip_suffix(".html")
.expect("only paths with this suffix should get here"),
};
render_template(&shared.handlebars, path, &TemplateArgs::default())
.map(|body| HttpResponse::Ok().body(body))
}
#[post("/mail.html")]
pub(crate) async fn mail_post(
shared: web::Data<SharedData<'_>>,
form: web::Form<Mail>,
) -> actix_web::Result<impl Responder> {
info!("{:?}", form);
let args = TemplateArgs::builder()
.flash(Flash::new("Mail successfully sent!", FlashType::Success))
.build();
render_template(&shared.handlebars, "mail", &args).map(|body| HttpResponse::Ok().body(body))
}

View file

@ -1,78 +0,0 @@
/// Serve templates "statically".
///
/// In the generic case, we don't want to do any processing on
/// template pages. They should be served "statically", but we want to
/// transform the bits of default handlebars templating - currently
/// setting the flash to nothing.
///
/// This module implements a catchall route for this purpose.
use rocket::request::FlashMessage;
use std::ffi::OsString;
use rocket::http::uri::Segments;
use rocket::request::FromSegments;
use rocket::response::status;
use rocket::{get, routes, Route};
use rocket_contrib::templates::Template;
use crate::context::Context;
pub struct HTMLPage {
pub page: OsString,
}
impl<'a> FromSegments<'a> for HTMLPage {
type Error = &'static str;
fn from_segments(segments: Segments<'a>) -> Result<Self, Self::Error> {
let page = segments
.into_path_buf(false)
.map_err(|_| "Invalid segments")?;
page.extension()
.map(|extension| {
if extension == "html" {
Some(HTMLPage {
page: page
.file_stem()
.expect("Should exist if the extension does")
.to_os_string(),
})
} else {
None
}
})
.flatten()
.ok_or("Invalid page name")
}
}
#[get("/")]
pub fn static_index() -> Template {
Template::render("index", Context::default())
}
#[get("/<path..>")]
pub fn static_templates(
path: HTMLPage,
flash: Option<FlashMessage>,
) -> Result<Template, status::BadRequest<String>> {
let path = path.page.into_string().map_err(|path| {
status::BadRequest(Some(format!("Invalid path: {}", path.to_string_lossy())))
})?;
if let Some(flash) = flash {
Ok(Template::render(
path,
Context {
flash: Some(flash.into()),
},
))
} else {
Ok(Template::render(path, Context::default()))
}
}
pub fn routes() -> Vec<Route> {
routes![static_index, static_templates]
}

105
src/template_utils.rs Normal file
View file

@ -0,0 +1,105 @@
use log::error;
use serde::Serialize;
use crate::errors::UserError;
pub fn render_template(
handlebars: &handlebars::Handlebars,
name: &str,
args: &TemplateArgs,
) -> actix_web::Result<String> {
if handlebars.has_template(name) {
Ok(handlebars
.render(name, args)
.map_err(|_| UserError::InternalError)?)
} else {
error!("template not found: {}", name);
Err(UserError::NotFound)?
}
}
/** All arguments that can be given to a template. */
#[derive(Default, Serialize)]
pub struct TemplateArgs {
flash: Option<Flash>,
error: Option<ErrorMessage>,
}
impl TemplateArgs {
pub fn builder() -> TemplateArgsBuilder {
TemplateArgsBuilder::new()
}
}
pub struct TemplateArgsBuilder {
flash: Option<Flash>,
error: Option<ErrorMessage>,
}
impl TemplateArgsBuilder {
pub fn new() -> Self {
TemplateArgsBuilder {
flash: None,
error: None,
}
}
pub fn flash(mut self, flash: Flash) -> Self {
self.flash = Some(flash);
self
}
pub fn error_page(mut self, error: ErrorMessage) -> Self {
self.error = Some(error);
self
}
pub fn build(self) -> TemplateArgs {
TemplateArgs {
flash: self.flash,
error: self.error,
}
}
}
/** A flash message that should be displayed as a notification on the page. */
#[derive(Serialize)]
pub struct Flash {
message: String,
#[serde(rename = "type")]
level: FlashType,
}
impl Flash {
pub fn new(message: &str, level: FlashType) -> Self {
Self {
message: message.to_string(),
level,
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "lowercase")]
pub enum FlashType {
Info,
Success,
Warning,
Danger,
}
/** Contents of an error page. */
#[derive(Serialize)]
pub struct ErrorMessage {
message: String,
status_code: u16,
}
impl ErrorMessage {
pub fn new(message: &str, status_code: u16) -> Self {
Self {
message: message.to_string(),
status_code,
}
}
}