From a7937158ffbf3bd5e81fdc06b7761ffe0caa80c8 Mon Sep 17 00:00:00 2001 From: seu Date: Fri, 2 Nov 2018 11:50:57 +0100 Subject: [PATCH] Initial mail support. Deletion and verification mails are now sent via sendmail --- Cargo.toml | 7 ++ src/mail.rs | 65 +++++++++++++++++++ src/main.rs | 9 +++ src/web/mod.rs | 27 +++++++- src/web/upload.rs | 154 ++++++++++++++++++++++++++++++-------------- web/index.html.hbs | 4 +- web/upload.html.hbs | 2 +- web/verify.html.hbs | 2 +- 8 files changed, 215 insertions(+), 55 deletions(-) create mode 100644 src/mail.rs diff --git a/Cargo.toml b/Cargo.toml index 93c95a5..03e20f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,13 @@ parking_lot = "0.6" structopt = "0.2" url = "1.6" hex = "0.3" +lettre_email = "0.8" + +[dependencies.lettre] +version = "0.8" +default-features = false +# smtp-transport doesn't build (openssl problem) +features = ["file-transport", "sendmail-transport"] [dependencies.rocket_contrib] version = "0" diff --git a/src/mail.rs b/src/mail.rs new file mode 100644 index 0000000..1821ce9 --- /dev/null +++ b/src/mail.rs @@ -0,0 +1,65 @@ +use rocket_contrib::Template; + +use lettre::{SendmailTransport, EmailTransport}; +use lettre_email::EmailBuilder; + +use serde::Serialize; + +use Result; +use types::Email; + +#[derive(Serialize, Clone)] +pub struct Context{ + pub token: String, + pub userid: String, + pub domain: String, +} + +fn send_mail(to: &Email, subject: &str, template_dir: &str, + template_base: &str, domain: &str, ctx: T) + -> Result<()> where T: Serialize + Clone +{ + let html = Template::show(template_dir, format!("{}-html", template_base), ctx.clone()); + let txt = Template::show(template_dir, format!("{}-txt", template_base), ctx); + let email = EmailBuilder::new() + .to(to.to_string()) + .from(format!("noreply@{}", domain)) + .subject(subject) + .alternative( + html.ok_or("Email template failed to render")?, + txt.ok_or("Email template failed to render")?) + .build().unwrap(); + + let mut sender = SendmailTransport::new(); + + sender.send(&email)?; + Ok(()) +} + +pub fn send_verification_mail(userid: &Email, token: &str, template_dir: &str, + domain: &str) +-> Result<()> +{ + let ctx = Context{ + token: token.to_string(), + userid: userid.to_string(), + domain: domain.to_string(), + }; + + send_mail(userid, "Please verify your email address", template_dir, + "verify-email", domain, ctx) +} + +pub fn send_confirmation_mail(userid: &Email, token: &str, template_dir: &str, + domain: &str) +-> Result<()> +{ + let ctx = Context{ + token: token.to_string(), + userid: userid.to_string(), + domain: domain.to_string(), + }; + + send_mail(userid, "Please confirm deletion of your key", template_dir, + "confirm-email", domain, ctx) +} diff --git a/src/main.rs b/src/main.rs index 78a3f67..1e7c32c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,10 +23,13 @@ extern crate rand; extern crate tempfile; extern crate parking_lot; extern crate structopt; +extern crate lettre; +extern crate lettre_email; mod web; mod database; mod types; +mod mail; mod errors { error_chain!{ @@ -39,6 +42,7 @@ mod errors { StringUtf8Error(::std::string::FromUtf8Error); StrUtf8Error(::std::str::Utf8Error); HexError(::hex::FromHexError); + SendmailError(::lettre::sendmail::error::Error); } } } @@ -62,6 +66,11 @@ pub struct Opt { /// Port and address to listen on. #[structopt(short = "l", long = "listen", default_value = "0.0.0.0:8080")] listen: String, + /// FQDN of the server. Used in templates. + #[structopt(short = "D", long = "domain", default_value = "localhost")] + domain: String, + + } fn main() { diff --git a/src/web/mod.rs b/src/web/mod.rs index fbf97fd..37cf63e 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -50,6 +50,8 @@ mod templates { } struct StaticDir(String); +pub struct MailTemplateDir(String); +pub struct Domain(String); impl<'a, 'r> FromRequest<'a, 'r> for queries::Hkp { type Error = (); @@ -165,10 +167,13 @@ fn verify(db: rocket::State, token: String) } } -#[get("/delete/")] -fn delete(db: rocket::State, fpr: String) +#[get("/vks/delete/")] +fn delete(db: rocket::State, fpr: String, + tmpl: State, domain: State) -> result::Result> { + use mail::send_confirmation_mail; + let fpr = match Fingerprint::from_str(&fpr) { Ok(fpr) => fpr, Err(_) => { @@ -286,6 +291,7 @@ pub fn serve(opt: &Opt, db: Polymorphic) -> Result<()> { .root(opt.base.clone()) .extra("template_dir", format!("{}/templates", opt.base.display())) .extra("static_dir", format!("{}/public", opt.base.display())) + .extra("domain", opt.domain.clone()) .finalize()?; let routes = routes![ // infra @@ -313,6 +319,23 @@ pub fn serve(opt: &Opt, db: Polymorphic) -> Result<()> { Ok(rocket.manage(StaticDir(static_dir))) })) + .attach(AdHoc::on_attach(|rocket| { + let static_dir = rocket.config() + .get_str("template_dir") + .unwrap() + .to_string(); + + Ok(rocket.manage(MailTemplateDir(static_dir))) + })) + .attach(AdHoc::on_attach(|rocket| { + let static_dir = rocket.config() + .get_str("domain") + .unwrap() + .to_string(); + + Ok(rocket.manage(Domain(static_dir))) + })) + .mount("/", routes) .manage(db) .launch(); diff --git a/src/web/upload.rs b/src/web/upload.rs index f59ff9d..38f8618 100644 --- a/src/web/upload.rs +++ b/src/web/upload.rs @@ -7,8 +7,14 @@ use rocket::http::{ContentType, Status}; use rocket::response::status::Custom; use rocket_contrib::Template; +use types::Email; +use mail::send_verification_mail; +use web::{Domain, MailTemplateDir}; use database::{Database, Polymorphic}; +use std::io::Read; +use std::str::FromStr; + mod template { #[derive(Serialize)] pub struct Token { @@ -17,82 +23,132 @@ mod template { } #[derive(Serialize)] - pub struct Context { + pub struct Verify { pub tokens: Vec, } } -#[post("/keys", data = "")] +#[post("/pks/add", data = "")] // signature requires the request to have a `Content-Type` -pub fn multipart_upload(db: State, cont_type: &ContentType, data: Data) -> Result> { - // this and the next check can be implemented as a request guard but it seems like just - // more boilerplate than necessary - if !cont_type.is_form_data() { - return Err(Custom( - Status::BadRequest, - "Content-Type not multipart/form-data".into() - )); - } - - let (_, boundary) = cont_type.params().find(|&(k, _)| k == "boundary").ok_or_else( +pub fn multipart_upload(db: State, cont_type: &ContentType, + data: Data, tmpl: State, + domain: State) + -> Result> +{ + if cont_type.is_form_data() { + // multipart/form-data + let (_, boundary) = cont_type.params().find(|&(k, _)| k == "boundary").ok_or_else( || Custom( Status::BadRequest, "`Content-Type: multipart/form-data` boundary param not provided".into() - ) - )?; + ) + )?; - process_upload(boundary, data, db.inner()) + process_upload(boundary, data, db.inner(), &tmpl.0, &domain.0) + } else if cont_type.is_form() { + use rocket::request::FormItems; + use std::io::Cursor; + + // application/x-www-form-urlencoded + let mut buf = Vec::default(); + + data.stream_to(&mut buf).or_else(|_| { + Err(Custom(Status::BadRequest, + "`Content-Type: application/x-www-form-urlencoded` not valid".into())) + })?; + + for (key, value) in FormItems::from(&*String::from_utf8_lossy(&buf)) { + let decoded_value = value.url_decode().or_else(|_| { + Err(Custom(Status::BadRequest, + "`Content-Type: application/x-www-form-urlencoded` not valid".into())) + })?; + + match key.as_str() { + "keytext" => { + return process_key(Cursor::new(decoded_value.as_bytes()), + &db, &tmpl.0, &domain.0); + } + _ => { /* skip */ } + } + } + + Err(Custom(Status::BadRequest, "Not a PGP public key".into())) + } else { + Err(Custom(Status::BadRequest, "Content-Type not a form".into())) + } } -fn process_upload(boundary: &str, data: Data, db: &Polymorphic) -> Result> { +fn process_upload(boundary: &str, data: Data, db: &Polymorphic, tmpl: &str, + domain: &str) + -> Result> +{ // saves all fields, any field longer than 10kB goes to a temporary directory // Entries could implement FromData though that would give zero control over // how the files are saved; Multipart would be a good impl candidate though match Multipart::with_body(data.open(), boundary).save().temp() { - Full(entries) => process_entries(entries, db), - Partial(partial, _) => { - process_entries(partial.entries, db) - }, + Full(entries) => process_multipart(entries, db, tmpl, domain), + Partial(partial, _) => process_multipart(partial.entries, db, tmpl, domain), Error(err) => Err(Custom(Status::InternalServerError, err.to_string())), } } -// having a streaming output would be nice; there's one for returning a `Read` impl -// but not one that you can `write()` to -fn process_entries(entries: Entries, db: &Polymorphic) -> Result> { - use openpgp::TPK; - - match entries.fields.get(&"key".to_string()) { +fn process_multipart(entries: Entries, db: &Polymorphic, tmpl: &str, + domain: &str) + -> Result> +{ + match entries.fields.get(&"keytext".to_string()) { Some(ent) if ent.len() == 1 => { let reader = ent[0].data.readable().map_err(|err| { Custom(Status::InternalServerError, err.to_string()) })?; - match TPK::from_reader(reader) { - Ok(tpk) => { - match db.merge_or_publish(tpk) { - Ok(tokens) => { - let tokens = tokens - .into_iter().map(|(uid,tok)| { - template::Token{ userid: uid.to_string(), token: tok } - }).collect::>(); - let context = template::Context{ - tokens: tokens - }; - - Ok(Template::render("upload", context)) - } - Err(err) => - Err(Custom(Status::InternalServerError, - format!("{:?}", err))), - } - } - Err(_) => Err(Custom(Status::BadRequest, - "Not a PGP public key".into())), - } + process_key(reader, db, tmpl, domain) } Some(_) | None => Err(Custom(Status::BadRequest, "Not a PGP public key".into())), } } +fn process_key(reader: R, db: &Polymorphic, tmpl: &str, domain: &str) + -> Result> where R: Read +{ + use openpgp::{Reader, TPK}; + let reader = Reader::from_reader(reader).or_else(|_| { + Err(Custom(Status::BadRequest, + "`Content-Type: application/x-www-form-urlencoded` not valid".into())) + })?; + + match TPK::from_reader(reader) { + Ok(tpk) => { + match db.merge_or_publish(tpk) { + Ok(tokens) => { + let tokens = tokens + .into_iter().map(|(uid,tok)| { + template::Token{ userid: uid.to_string(), token: tok } + }).collect::>(); + + // send out emails + for tok in tokens.iter() { + let &template::Token{ ref userid, ref token } = tok; + + Email::from_str(userid).and_then(|email| { + send_verification_mail(&email, token, tmpl, domain) + }).map_err(|err| { + Custom(Status::InternalServerError, format!("{:?}", err)) + })?; + } + + let context = template::Verify{ + tokens: tokens + }; + + Ok(Template::render("upload", context)) + } + Err(err) => + Err(Custom(Status::InternalServerError, + format!("{:?}", err))), + } + } + Err(_) => Err(Custom(Status::BadRequest, "Not a PGP public key".into())), + } +} diff --git a/web/index.html.hbs b/web/index.html.hbs index a8da66b..0559c4a 100644 --- a/web/index.html.hbs +++ b/web/index.html.hbs @@ -10,8 +10,8 @@

Upload your key

-
- + +
{{/layout}} diff --git a/web/upload.html.hbs b/web/upload.html.hbs index 8620caa..9d06550 100644 --- a/web/upload.html.hbs +++ b/web/upload.html.hbs @@ -3,7 +3,7 @@ {{#if tokens}} {{else}} diff --git a/web/verify.html.hbs b/web/verify.html.hbs index c9ce71f..a896f7c 100644 --- a/web/verify.html.hbs +++ b/web/verify.html.hbs @@ -2,7 +2,7 @@ {{#if verified }}

Email verified

You've verified {{ userid }} successfully. Everybody who knows - your email address is no able to find your key. You can delete your key any time you want.

{{else}}

Email verification failed