Add email signup feature

This commit is contained in:
Kitaiti Makoto 2022-01-06 20:18:20 +09:00
parent 9b4c678aa9
commit b6d38536e3
8 changed files with 364 additions and 27 deletions

View File

@ -81,36 +81,33 @@ impl EmailSignup {
}) })
} }
pub fn confirm(conn: &DbConn, token: &Token, email: &str) -> Result<Self> { pub fn find_by_token(conn: &DbConn, token: Token) -> Result<Self> {
Self::ensure_user_not_exist_by_email(conn, email)?; let signup = email_signups::table
let signup: Self = email_signups::table
.filter(email_signups::token.eq(token.as_str())) .filter(email_signups::token.eq(token.as_str()))
.first(&**conn) .first::<Self>(&**conn)
.map_err(Error::from)?; .map_err(Error::from)?;
if signup.expired() {
Self::delete_existings_by_email(conn, email)?;
return Err(Error::Expired);
}
Ok(signup) Ok(signup)
} }
pub fn complete( pub fn confirm(&self, conn: &DbConn) -> Result<()> {
&self, Self::ensure_user_not_exist_by_email(conn, &self.email)?;
conn: &DbConn, if self.expired() {
username: String, Self::delete_existings_by_email(conn, &self.email)?;
display_name: String, return Err(Error::Expired);
summary: &str, }
password: String, Ok(())
) -> Result<User> { }
pub fn complete(&self, conn: &DbConn, username: String, password: String) -> Result<User> {
Self::ensure_user_not_exist_by_email(conn, &self.email)?; Self::ensure_user_not_exist_by_email(conn, &self.email)?;
let user = NewUser::new_local( let user = NewUser::new_local(
conn, conn,
username, username,
display_name, "".to_string(),
Role::Normal, Role::Normal,
summary, "",
self.email.clone(), self.email.clone(),
Some(password), Some(User::hash_pass(&password)?),
)?; )?;
self.delete(conn)?; self.delete(conn)?;
Ok(user) Ok(user)

View File

@ -149,6 +149,10 @@ Then try to restart Plume.
routes::comments::create, routes::comments::create,
routes::comments::delete, routes::comments::delete,
routes::comments::activity_pub, 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::index,
routes::instance::admin, routes::instance::admin,
routes::instance::admin_mod, routes::instance::admin_mod,

233
src/routes/email_signups.rs Normal file
View File

@ -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 = "<form>")]
pub fn create(
mail: State<'_, Arc<Mutex<Mailer>>>,
form: LenientForm<EmailSignupForm>,
conn: DbConn,
rockets: PlumeRocket,
) -> Result<RespondOrRedirect, Ructe> {
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/<token>")]
pub fn show(token: String, conn: DbConn, rockets: PlumeRocket) -> Result<Ructe, ErrorPage> {
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 = "<form>")]
pub fn signup(
form: LenientForm<NewUserForm>,
conn: DbConn,
rockets: PlumeRocket,
) -> Result<RespondOrRedirect, Status> {
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."
),
)))
}

View File

@ -190,6 +190,7 @@ fn post_to_atom(post: Post, conn: &Connection) -> Entry {
pub mod blogs; pub mod blogs;
pub mod comments; pub mod comments;
pub mod email_signups;
pub mod errors; pub mod errors;
pub mod instance; pub mod instance;
pub mod likes; pub mod likes;

View File

@ -10,14 +10,16 @@ use std::{borrow::Cow, collections::HashMap};
use validator::{Validate, ValidationError, ValidationErrors}; use validator::{Validate, ValidationError, ValidationErrors};
use crate::inbox; 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 crate::template_utils::{IntoContext, Ructe};
use plume_common::activity_pub::{broadcast, ActivityStream, ApRequest, Id}; use plume_common::activity_pub::{broadcast, ActivityStream, ApRequest, Id};
use plume_common::utils; use plume_common::utils;
use plume_models::{ use plume_models::{
blogs::Blog, db_conn::DbConn, follows, headers::Headers, inbox::inbox as local_inbox, blogs::Blog, db_conn::DbConn, follows, headers::Headers, inbox::inbox as local_inbox,
instance::Instance, medias::Media, posts::Post, reshares::Reshare, safe_string::SafeString, 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")] #[get("/me")]
@ -260,12 +262,23 @@ pub fn activity_details(
#[get("/users/new")] #[get("/users/new")]
pub fn new(conn: DbConn, rockets: PlumeRocket) -> Result<Ructe, ErrorPage> { pub fn new(conn: DbConn, rockets: PlumeRocket) -> Result<Ructe, ErrorPage> {
Ok(render!(users::new( use SignupStrategy::*;
&(&conn, &rockets).to_context(),
Instance::get_local()?.open_registrations, let rendered = match CONFIG.signup {
&NewUserForm::default(), Password => render!(users::new(
ValidationErrors::default() &(&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("/@/<name>/edit")] #[get("/@/<name>/edit")]

View File

@ -0,0 +1,9 @@
@use crate::template_utils::*;
@use crate::templates::base;
@(ctx: BaseContext)
@:base(ctx, i18n!(ctx.1, "Registration"), {}, {}, {
<h1>@i18n!(ctx.1, "Check your inbox!")</h1>
<p>@i18n!(ctx.1, "We sent a mail to the address you gave us, with a link for registration.")</p>
})

View File

@ -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 {
<h1>@i18n!(ctx.1, "Create an account")</h1>
<form method="post" action="@uri!(email_signups::signup)">
@if let Some(ValidationErrorsKind::Field(errs)) = errors.clone().errors().get("__all__") {
<p class="error">@errs[0].message.as_ref().unwrap_or(&Cow::from("Unknown error"))</p>
}
@(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))
<input type="hidden" name="email" value="@form.email">
<input type="hidden" name="token" value="@form.token">
<input type="submit" value="@i18n!(ctx.1, "Create your account")" />
</form>
} else {
<p class="center">@i18n!(ctx.1, "Apologies, but registrations are closed on this particular instance. You can, however, find a different one.")</p>
}
})

View File

@ -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 {
<h1>@i18n!(ctx.1, "Create an account")</h1>
<form method="post" action="@uri!(email_signups::create)">
@if let Some(ValidationErrorsKind::Field(errs)) = errors.clone().errors().get("__all__") {
<p class="error">@errs[0].message.as_ref().unwrap_or(&Cow::from("Unknown error"))</p>
}
@(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))
<input type="submit" value="@i18n!(ctx.1, "Create your account")" />
</form>
} else {
<p class="center">@i18n!(ctx.1, "Apologies, but registrations are closed on this particular instance. You can, however, find a different one.")</p>
}
})