diff --git a/plume-models/src/email_signups.rs b/plume-models/src/email_signups.rs index fbda656b..fe17e34f 100644 --- a/plume-models/src/email_signups.rs +++ b/plume-models/src/email_signups.rs @@ -81,36 +81,33 @@ impl EmailSignup { }) } - pub fn confirm(conn: &DbConn, token: &Token, email: &str) -> Result { - Self::ensure_user_not_exist_by_email(conn, email)?; - let signup: Self = email_signups::table + pub fn find_by_token(conn: &DbConn, token: Token) -> Result { + let signup = email_signups::table .filter(email_signups::token.eq(token.as_str())) - .first(&**conn) + .first::(&**conn) .map_err(Error::from)?; - if signup.expired() { - Self::delete_existings_by_email(conn, email)?; - return Err(Error::Expired); - } Ok(signup) } - pub fn complete( - &self, - conn: &DbConn, - username: String, - display_name: String, - summary: &str, - password: String, - ) -> Result { + pub fn confirm(&self, conn: &DbConn) -> Result<()> { + Self::ensure_user_not_exist_by_email(conn, &self.email)?; + if self.expired() { + Self::delete_existings_by_email(conn, &self.email)?; + return Err(Error::Expired); + } + Ok(()) + } + + pub fn complete(&self, conn: &DbConn, username: String, password: String) -> Result { Self::ensure_user_not_exist_by_email(conn, &self.email)?; let user = NewUser::new_local( conn, username, - display_name, + "".to_string(), Role::Normal, - summary, + "", self.email.clone(), - Some(password), + Some(User::hash_pass(&password)?), )?; self.delete(conn)?; Ok(user) diff --git a/src/main.rs b/src/main.rs index 58cb3abe..854ba825 100755 --- a/src/main.rs +++ b/src/main.rs @@ -149,6 +149,10 @@ Then try to restart Plume. routes::comments::create, routes::comments::delete, routes::comments::activity_pub, + routes::email_signups::create, + routes::email_signups::created, + routes::email_signups::show, + routes::email_signups::signup, routes::instance::index, routes::instance::admin, routes::instance::admin_mod, diff --git a/src/routes/email_signups.rs b/src/routes/email_signups.rs new file mode 100644 index 00000000..c3b65957 --- /dev/null +++ b/src/routes/email_signups.rs @@ -0,0 +1,233 @@ +use crate::{ + mail::{build_mail, Mailer}, + routes::{errors::ErrorPage, RespondOrRedirect}, + template_utils::{IntoContext, Ructe}, +}; +use plume_models::{ + db_conn::DbConn, email_signups::EmailSignup, instance::Instance, lettre::Transport, + signups::Strategy as SignupStrategy, Error, PlumeRocket, CONFIG, +}; +use rocket::{ + http::Status, + request::LenientForm, + response::{Flash, Redirect}, + State, +}; +use std::sync::{Arc, Mutex}; +use tracing::warn; +use validator::{Validate, ValidationError, ValidationErrors}; + +#[derive(Default, FromForm, Validate)] +#[validate(schema( + function = "emails_match", + skip_on_field_errors = "false", + message = "Emails are not matching" +))] +pub struct EmailSignupForm { + #[validate(email(message = "Invalid email"))] + pub email: String, + #[validate(email(message = "Invalid email"))] + pub email_confirmation: String, +} + +fn emails_match(form: &EmailSignupForm) -> Result<(), ValidationError> { + if form.email_confirmation == form.email { + Ok(()) + } else { + Err(ValidationError::new("emails_match")) + } +} + +#[derive(Default, FromForm, Validate)] +#[validate(schema( + function = "passwords_match", + skip_on_field_errors = "false", + message = "Passwords are not matching" +))] +pub struct NewUserForm { + #[validate(length(min = "1", message = "Username should be at least 1 characters long"))] + pub username: String, + #[validate(length(min = "8", message = "Password should be at least 8 characters long"))] + pub password: String, + #[validate(length(min = "8", message = "Password should be at least 8 characters long"))] + pub password_confirmation: String, + pub email: String, + pub token: String, +} + +pub fn passwords_match(form: &NewUserForm) -> Result<(), ValidationError> { + if form.password != form.password_confirmation { + Err(ValidationError::new("password_match")) + } else { + Ok(()) + } +} + +#[post("/email_signups/new", data = "
")] +pub fn create( + mail: State<'_, Arc>>, + form: LenientForm, + conn: DbConn, + rockets: PlumeRocket, +) -> Result { + use RespondOrRedirect::{FlashRedirect, Response}; + + if !matches!(CONFIG.signup, SignupStrategy::Email) { + return Ok(FlashRedirect(Flash::error( + Redirect::to(uri!(super::user::new)), + i18n!( + rockets.intl.catalog, + "Email registrations are not enabled. Please restart." + ), + ))); + } + + let registration_open = !Instance::get_local() + .map(|i| i.open_registrations) + .unwrap_or(true); + + if registration_open { + return Ok(FlashRedirect(Flash::error( + Redirect::to(uri!(super::user::new)), + i18n!( + rockets.intl.catalog, + "Registrations are closed on this instance." + ), + ))); // Actually, it is an error + } + let mut form = form.into_inner(); + form.email = form.email.trim().to_owned(); + form.validate().map_err(|err| { + render!(email_signups::new( + &(&conn, &rockets).to_context(), + registration_open, + &form, + err + )) + })?; + let res = EmailSignup::start(&conn, &form.email); + if let Some(err) = res.as_ref().err() { + return Ok(match err { + Error::UserAlreadyExists => { + // TODO: Notify to admin (and the user?) + warn!("Registration attempted for existing user: {}. Registraion halted and email sending skipped.", &form.email); + Response(render!(email_signups::create( + &(&conn, &rockets).to_context() + ))) + } + Error::NotFound => { + Response(render!(errors::not_found(&(&conn, &rockets).to_context()))) + } + _ => Response(render!(errors::not_found(&(&conn, &rockets).to_context()))), // FIXME + }); + } + let token = res.unwrap(); + let url = format!( + "https://{}{}", + CONFIG.base_url, + uri!(show: token = token.to_string()) + ); + let message = build_mail( + form.email, + i18n!(rockets.intl.catalog, "User registration"), + i18n!(rockets.intl.catalog, "Here is the link for registration: {0}"; url), + ) + .expect("Mail configuration has already been done at ignition process"); + // TODO: Render error page + if let Some(ref mut mailer) = *mail.lock().unwrap() { + mailer.send(message.into()).ok(); // TODO: Render error page + } + + Ok(Response(render!(email_signups::create( + &(&conn, &rockets).to_context() + )))) +} + +#[get("/email_signups/new")] +pub fn created(conn: DbConn, rockets: PlumeRocket) -> Ructe { + render!(email_signups::create(&(&conn, &rockets).to_context())) +} + +#[get("/email_signups/")] +pub fn show(token: String, conn: DbConn, rockets: PlumeRocket) -> Result { + let signup = EmailSignup::find_by_token(&conn, token.into())?; + let confirmation = signup.confirm(&conn); + if let Some(err) = confirmation.err() { + match err { + Error::Expired => { + return Ok(render!(email_signups::new( + &(&conn, &rockets).to_context(), + Instance::get_local()?.open_registrations, + &EmailSignupForm::default(), + ValidationErrors::default() + ))) + } // TODO: Flash and redirect + Error::NotFound => return Err(Error::NotFound.into()), + _ => return Err(Error::NotFound.into()), // FIXME + } + } + + let mut form = NewUserForm::default(); + form.email = signup.email; + form.token = signup.token; + Ok(render!(email_signups::edit( + &(&conn, &rockets).to_context(), + Instance::get_local()?.open_registrations, + &form, + ValidationErrors::default() + ))) +} + +#[post("/email_signups/signup", data = "")] +pub fn signup( + form: LenientForm, + conn: DbConn, + rockets: PlumeRocket, +) -> Result { + use RespondOrRedirect::{FlashRedirect, Response}; + + let instance = Instance::get_local().map_err(|e| { + warn!("{:?}", e); + Status::InternalServerError + })?; + if let Some(err) = form.validate().err() { + return Ok(Response(render!(email_signups::edit( + &(&conn, &rockets).to_context(), + instance.open_registrations, + &form, + err + )))); + } + let signup = EmailSignup::find_by_token(&conn, form.token.clone().into()) + .map_err(|_| Status::NotFound)?; + if form.email != signup.email { + let mut err = ValidationErrors::default(); + err.add("email", ValidationError::new("Email couldn't changed")); + let form = NewUserForm { + username: form.username.clone(), + password: form.password.clone(), + password_confirmation: form.password_confirmation.clone(), + email: signup.email, + token: form.token.clone(), + }; + return Ok(Response(render!(email_signups::edit( + &(&conn, &rockets).to_context(), + instance.open_registrations, + &form, + err + )))); + } + let _user = signup + .complete(&conn, form.username.clone(), form.password.clone()) + .map_err(|e| { + warn!("{:?}", e); + Status::UnprocessableEntity + })?; + Ok(FlashRedirect(Flash::success( + Redirect::to(uri!(super::session::new: m = _)), + i18n!( + rockets.intl.catalog, + "Your account has been created. Now you just need to log in, before you can use it." + ), + ))) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index fd47e1cd..56e4d90c 100755 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -190,6 +190,7 @@ fn post_to_atom(post: Post, conn: &Connection) -> Entry { pub mod blogs; pub mod comments; +pub mod email_signups; pub mod errors; pub mod instance; pub mod likes; diff --git a/src/routes/user.rs b/src/routes/user.rs index 30b7ecb7..487d28d3 100644 --- a/src/routes/user.rs +++ b/src/routes/user.rs @@ -10,14 +10,16 @@ use std::{borrow::Cow, collections::HashMap}; use validator::{Validate, ValidationError, ValidationErrors}; use crate::inbox; -use crate::routes::{errors::ErrorPage, Page, RemoteForm, RespondOrRedirect}; +use crate::routes::{ + email_signups::EmailSignupForm, errors::ErrorPage, Page, RemoteForm, RespondOrRedirect, +}; use crate::template_utils::{IntoContext, Ructe}; use plume_common::activity_pub::{broadcast, ActivityStream, ApRequest, Id}; use plume_common::utils; use plume_models::{ blogs::Blog, db_conn::DbConn, follows, headers::Headers, inbox::inbox as local_inbox, instance::Instance, medias::Media, posts::Post, reshares::Reshare, safe_string::SafeString, - users::*, Error, PlumeRocket, CONFIG, + signups::Strategy as SignupStrategy, users::*, Error, PlumeRocket, CONFIG, }; #[get("/me")] @@ -260,12 +262,23 @@ pub fn activity_details( #[get("/users/new")] pub fn new(conn: DbConn, rockets: PlumeRocket) -> Result { - Ok(render!(users::new( - &(&conn, &rockets).to_context(), - Instance::get_local()?.open_registrations, - &NewUserForm::default(), - ValidationErrors::default() - ))) + use SignupStrategy::*; + + let rendered = match CONFIG.signup { + Password => render!(users::new( + &(&conn, &rockets).to_context(), + Instance::get_local()?.open_registrations, + &NewUserForm::default(), + ValidationErrors::default() + )), + Email => render!(email_signups::new( + &(&conn, &rockets).to_context(), + Instance::get_local()?.open_registrations, + &EmailSignupForm::default(), + ValidationErrors::default() + )), + }; + Ok(rendered) } #[get("/@//edit")] diff --git a/templates/email_signups/create.rs.html b/templates/email_signups/create.rs.html new file mode 100644 index 00000000..41e7483f --- /dev/null +++ b/templates/email_signups/create.rs.html @@ -0,0 +1,9 @@ +@use crate::template_utils::*; +@use crate::templates::base; + +@(ctx: BaseContext) + +@:base(ctx, i18n!(ctx.1, "Registration"), {}, {}, { +

@i18n!(ctx.1, "Check your inbox!")

+

@i18n!(ctx.1, "We sent a mail to the address you gave us, with a link for registration.")

+}) diff --git a/templates/email_signups/edit.rs.html b/templates/email_signups/edit.rs.html new file mode 100644 index 00000000..f6beebb5 --- /dev/null +++ b/templates/email_signups/edit.rs.html @@ -0,0 +1,43 @@ +@use std::borrow::Cow; +@use validator::{ValidationErrors, ValidationErrorsKind}; +@use crate::templates::base; +@use crate::template_utils::*; +@use crate::routes::email_signups::NewUserForm; +@use crate::routes::*; + +@(ctx: BaseContext, enabled: bool, form: &NewUserForm, errors: ValidationErrors) + +@:base(ctx, i18n!(ctx.1, "Create your account"), {}, {}, { + @if enabled { +

@i18n!(ctx.1, "Create an account")

+ + @if let Some(ValidationErrorsKind::Field(errs)) = errors.clone().errors().get("__all__") { +

@errs[0].message.as_ref().unwrap_or(&Cow::from("Unknown error"))

+ } + + @(Input::new("username", i18n!(ctx.1, "Username")) + .default(&form.username) + .error(&errors) + .set_prop("required", "") + .html(ctx.1)) + @(Input::new("password", i18n!(ctx.1, "Password")) + .default(&form.password) + .error(&errors) + .set_prop("minlength", 8) + .input_type("password") + .html(ctx.1)) + @(Input::new("password_confirmation", i18n!(ctx.1, "Password confirmation")) + .default(&form.password_confirmation) + .error(&errors) + .set_prop("minlength", 8) + .input_type("password") + .html(ctx.1)) + + + + + + } else { +

@i18n!(ctx.1, "Apologies, but registrations are closed on this particular instance. You can, however, find a different one.")

+ } +}) diff --git a/templates/email_signups/new.rs.html b/templates/email_signups/new.rs.html new file mode 100644 index 00000000..0d0d19da --- /dev/null +++ b/templates/email_signups/new.rs.html @@ -0,0 +1,37 @@ +@use std::borrow::Cow; +@use validator::{ValidationErrors, ValidationErrorsKind}; +@use crate::templates::base; +@use crate::template_utils::*; +@use crate::routes::email_signups::EmailSignupForm; +@use crate::routes::*; + +@(ctx: BaseContext, enabled: bool, form: &EmailSignupForm, errors: ValidationErrors) + +@:base(ctx, i18n!(ctx.1, "Create your account"), {}, {}, { + @if enabled { +

@i18n!(ctx.1, "Create an account")

+
+ @if let Some(ValidationErrorsKind::Field(errs)) = errors.clone().errors().get("__all__") { +

@errs[0].message.as_ref().unwrap_or(&Cow::from("Unknown error"))

+ } + + @(Input::new("email", i18n!(ctx.1, "Email")) + .input_type("email") + .default(&form.email) + .error(&errors) + .set_prop("required", "") + .html(ctx.1)) + + @(Input::new("email_confirmation", i18n!(ctx.1, "Email confirmation")) + .input_type("email") + .default(&form.email_confirmation) + .error(&errors) + .set_prop("required", "") + .html(ctx.1)) + + +
+ } else { +

@i18n!(ctx.1, "Apologies, but registrations are closed on this particular instance. You can, however, find a different one.")

+ } +})