From b7359b3869d702d405a9a0901de818cb38752d5e Mon Sep 17 00:00:00 2001 From: Lev Kokotov Date: Fri, 6 Dec 2024 13:41:32 -0800 Subject: [PATCH 1/8] Users controller --- examples/users/src/main.rs | 6 +- examples/users/src/models.rs | 11 +++- examples/users/templates/signup.html | 19 ++++-- rwf-macros/src/lib.rs | 29 +++------ rwf-macros/src/prelude.rs | 1 + rwf-macros/src/route.rs | 34 ++++++++++ rwf-macros/src/user.rs | 52 +++++++++++++++ rwf/src/controller/error.rs | 3 + rwf/src/controller/mod.rs | 2 + rwf/src/controller/user.rs | 85 ++++++++++++++++++++++++ rwf/src/model/error.rs | 3 + rwf/src/model/mod.rs | 4 ++ rwf/src/model/row.rs | 8 +++ rwf/src/model/user.rs | 97 ++++++++++++++++++++++++++++ rwf/src/model/value.rs | 18 ++++++ 15 files changed, 346 insertions(+), 26 deletions(-) create mode 100644 rwf-macros/src/route.rs create mode 100644 rwf-macros/src/user.rs create mode 100644 rwf/src/controller/user.rs create mode 100644 rwf/src/model/user.rs diff --git a/examples/users/src/main.rs b/examples/users/src/main.rs index 332e67be..158c7b54 100644 --- a/examples/users/src/main.rs +++ b/examples/users/src/main.rs @@ -1,3 +1,4 @@ +use rwf::controller::LoginController; use rwf::{http::Server, prelude::*}; mod controllers; @@ -7,8 +8,11 @@ mod models; async fn main() { Logger::init(); + let signup: LoginController = + LoginController::new("templates/signup.html").redirect("/profile"); + Server::new(vec![ - route!("/signup" => controllers::Signup), + route!("/signup" => { signup }), route!("/login" => controllers::login), route!("/profile" => controllers::profile), ]) diff --git a/examples/users/src/models.rs b/examples/users/src/models.rs index 166096d0..e468dcc8 100644 --- a/examples/users/src/models.rs +++ b/examples/users/src/models.rs @@ -3,13 +3,22 @@ use rwf::crypto::{hash, hash_validate}; use rwf::prelude::*; use tokio::task::spawn_blocking; +#[derive(Clone, macros::Model, macros::UserModel)] +#[user_model(email, password_hash)] +pub struct User2 { + id: Option, + email: String, + password_hash: String, +} + pub enum UserLogin { NoSuchUser, WrongPassword, Ok(User), } -#[derive(Clone, macros::Model)] +#[derive(Clone, macros::Model, macros::UserModel)] +#[user_model(email, password)] pub struct User { id: Option, email: String, diff --git a/examples/users/templates/signup.html b/examples/users/templates/signup.html index fe25897e..b757dfdf 100644 --- a/examples/users/templates/signup.html +++ b/examples/users/templates/signup.html @@ -1,14 +1,12 @@ - - - + <%% "templates/head.html" %>
- <% if error %> + <% if error_user_exists %>
Account with this email already exists, and the password is incorrect.
@@ -16,12 +14,23 @@ <%= csrf_token() %>
- + + <% if error_identifier %> +
+ Provided email is not valid. +
+ <% end %>
+ + <% if error_password %> +
+ Provided password is not valid. +
+ <% end %>
diff --git a/rwf-macros/src/lib.rs b/rwf-macros/src/lib.rs index 784a686a..9afed369 100644 --- a/rwf-macros/src/lib.rs +++ b/rwf-macros/src/lib.rs @@ -1,17 +1,12 @@ extern crate proc_macro; -use proc_macro::TokenStream; - -use syn::{ - parse_macro_input, punctuated::Punctuated, Attribute, Data, DeriveInput, Expr, ItemFn, Meta, - ReturnType, Token, Type, Visibility, -}; - -use quote::quote; - mod model; mod prelude; mod render; +mod route; +mod user; + +use prelude::*; /// The `#[derive(Model)]` macro. /// @@ -522,16 +517,7 @@ pub fn error(input: TokenStream) -> TokenStream { /// ``` #[proc_macro] pub fn route(input: TokenStream) -> TokenStream { - let input = parse_macro_input!(input with Punctuated]>::parse_terminated); - let mut iter = input.into_iter(); - - let route = iter.next().unwrap(); - let controller = iter.next().unwrap(); - - quote! { - #controller::default().route(#route) - } - .into() + route::route_impl(input) } /// Create CRUD routes for the controller. @@ -737,3 +723,8 @@ pub fn controller(_args: TokenStream, input: TokenStream) -> TokenStream { } .into() } + +#[proc_macro_derive(UserModel, attributes(user_model))] +pub fn derive_user_model(input: TokenStream) -> TokenStream { + user::impl_derive_user_model(input) +} diff --git a/rwf-macros/src/prelude.rs b/rwf-macros/src/prelude.rs index d54fb655..55b1f54e 100644 --- a/rwf-macros/src/prelude.rs +++ b/rwf-macros/src/prelude.rs @@ -1,4 +1,5 @@ pub use proc_macro::TokenStream; pub use quote::quote; pub use syn::parse::*; +pub use syn::punctuated::Punctuated; pub use syn::*; diff --git a/rwf-macros/src/route.rs b/rwf-macros/src/route.rs new file mode 100644 index 00000000..0a353a9f --- /dev/null +++ b/rwf-macros/src/route.rs @@ -0,0 +1,34 @@ +use crate::prelude::*; + +struct RouteInput { + route: Expr, + controller: Expr, +} + +impl Parse for RouteInput { + fn parse(input: ParseStream) -> Result { + let route = input.parse()?; + let _arrow: Token![=>] = input.parse()?; + + let controller = input.parse()?; + + Ok(Self { route, controller }) + } +} + +pub(crate) fn route_impl(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as RouteInput); + + let route = input.route; + let controller = input.controller; + + let controller = match controller { + Expr::Path(expr) => quote! { #expr::default() }, + expr => quote! { #expr }, + }; + + quote! { + #controller.route(#route) + } + .into() +} diff --git a/rwf-macros/src/user.rs b/rwf-macros/src/user.rs new file mode 100644 index 00000000..83bf95a3 --- /dev/null +++ b/rwf-macros/src/user.rs @@ -0,0 +1,52 @@ +use super::prelude::*; + +struct UserModel { + identifier: Ident, + password: Ident, +} + +impl Parse for UserModel { + fn parse(input: parse::ParseStream) -> Result { + let identifier: Ident = input.parse()?; + let _: Token![,] = input.parse()?; + let password = input.parse()?; + + Ok(UserModel { + identifier, + password, + }) + } +} + +pub(crate) fn impl_derive_user_model(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + let ident = &input.ident; + + if let Some(attr) = input.attrs.first() { + match attr.meta { + Meta::List(ref attrs) => { + let attrs = syn::parse2::(attrs.tokens.clone()).unwrap(); + + let identifier = attrs.identifier.to_string(); + let password = attrs.password.to_string(); + + return quote! { + impl rwf::model::UserModel for #ident { + fn identifier_column() -> &'static str { + #identifier + } + + fn password_column() -> &'static str { + #password + } + } + } + .into(); + } + + _ => (), + } + } + + quote! {}.into() +} diff --git a/rwf/src/controller/error.rs b/rwf/src/controller/error.rs index 91f8ee41..90926392 100644 --- a/rwf/src/controller/error.rs +++ b/rwf/src/controller/error.rs @@ -48,6 +48,9 @@ pub enum Error { #[error("timeout exceeded")] TimeoutError(#[from] tokio::time::error::Elapsed), + + #[error("user error: {0}")] + UserError(#[from] crate::model::user::Error), } impl Error { diff --git a/rwf/src/controller/mod.rs b/rwf/src/controller/mod.rs index 50006213..7cd52fe6 100644 --- a/rwf/src/controller/mod.rs +++ b/rwf/src/controller/mod.rs @@ -33,6 +33,7 @@ pub mod middleware; pub mod ser; pub mod static_files; pub mod turbo_stream; +pub mod user; #[cfg(feature = "wsgi")] pub mod wsgi; @@ -50,6 +51,7 @@ pub use error::Error; pub use middleware::{Middleware, MiddlewareHandler, MiddlewareSet, Outcome, RateLimiter}; pub use static_files::{CacheControl, StaticFiles}; pub use turbo_stream::TurboStream; +pub use user::LoginController; use super::http::{ websocket::{self, DataFrame}, diff --git a/rwf/src/controller/user.rs b/rwf/src/controller/user.rs new file mode 100644 index 00000000..c0a68c92 --- /dev/null +++ b/rwf/src/controller/user.rs @@ -0,0 +1,85 @@ +use std::marker::PhantomData; + +use super::{Controller, Error, PageController}; +use crate::view::{Context, Template}; +use crate::{ + http::{Request, Response}, + model::{user::Error as UserError, UserModel}, +}; +use async_trait::async_trait; + +pub struct LoginController { + redirect: Option, + template: &'static str, + _marker: PhantomData, +} + +impl LoginController { + pub fn new(template: &'static str) -> Self { + Self { + redirect: None, + template, + _marker: PhantomData, + } + } + + pub fn redirect(mut self, redirect: impl ToString) -> Self { + self.redirect = Some(redirect.to_string()); + self + } + + fn error(&self, request: &Request, error: &str) -> Result { + let template = Template::load(self.template)?; + let mut ctx = Context::new(); + ctx.set(error, true)?; + ctx.set("request", request.clone())?; + Ok(Response::new().html(template.render(&ctx)?).code(400)) + } +} + +#[async_trait] +impl PageController for LoginController { + async fn get(&self, request: &Request) -> Result { + let mut ctx = Context::new(); + ctx.set("request", request.clone())?; + let template = Template::load(self.template)?; + Ok(Response::new().html(template.render(&ctx)?)) + } + + async fn post(&self, request: &Request) -> Result { + let form = request.form_data()?; + let identifier: String = match form.get_required("identifier") { + Ok(field) => field, + Err(_) => return self.error(request, "error_identifier"), + }; + let identifier = identifier.trim().to_string(); + let password: String = match form.get_required("password") { + Ok(field) => field, + Err(_) => return self.error(request, "error_password"), + }; + + match T::create_user(identifier, password).await { + Ok(user) => { + let id = user.id().integer()?; + let response = request.login(id); + + if let Some(ref redirect) = self.redirect { + Ok(response.redirect(redirect)) + } else { + Ok(response) + } + } + Err(err) => match err { + UserError::UserExists => return self.error(request, "error_user_exists"), + err => return Err(err.into()), + }, + } + } +} + +#[async_trait] +impl Controller for LoginController { + async fn handle(&self, request: &Request) -> Result { + PageController::handle(self, request).await + } +} diff --git a/rwf/src/model/error.rs b/rwf/src/model/error.rs index b2f692dd..495e9584 100644 --- a/rwf/src/model/error.rs +++ b/rwf/src/model/error.rs @@ -47,6 +47,9 @@ pub enum Error { "column \"{0}\" is missing from the row returned by the database,\ndid you forget to specify it in the query?" )] Column(String), + + #[error("value is not an integer")] + NotAnInteger, } impl Error { diff --git a/rwf/src/model/mod.rs b/rwf/src/model/mod.rs index b6d204e1..7369d8bf 100644 --- a/rwf/src/model/mod.rs +++ b/rwf/src/model/mod.rs @@ -28,6 +28,7 @@ pub mod prelude; pub mod row; pub mod select; pub mod update; +pub mod user; pub mod value; pub use column::{Column, Columns, ToColumn}; @@ -48,6 +49,7 @@ pub use pool::{get_connection, get_pool, start_transaction, Connection, Connecti pub use row::Row; pub use select::Select; pub use update::Update; +pub use user::UserModel; pub use value::{ToValue, Value}; /// Convert a PostgreSQL row to a Rust struct. Type conversions are handled by `tokio_postgres`. This only @@ -593,6 +595,8 @@ impl Query { } } + /// If a unique constraint on any of these columns is triggered, + /// the row will be automatically updated. pub fn unique_by(self, columns: &[impl ToColumn]) -> Self { match self { Query::Insert(insert) => Query::Insert(insert.unique_by(columns)), diff --git a/rwf/src/model/row.rs b/rwf/src/model/row.rs index c217205d..e0fd342d 100644 --- a/rwf/src/model/row.rs +++ b/rwf/src/model/row.rs @@ -49,10 +49,12 @@ impl std::ops::Deref for Row { } impl Row { + /// Create new row. pub fn new(row: tokio_postgres::Row) -> Self { Self { row: Arc::new(row) } } + /// Convert the row to a map of column names and values. pub fn values(self) -> Result, Error> { let mut result = HashMap::new(); for column in self.columns() { @@ -62,6 +64,12 @@ impl Row { Ok(result) } + + /// Consume the row and return the inner `tokio_postgres::Row` if there + /// are no more references to this row. + pub fn into_inner(self) -> Option { + Arc::into_inner(self.row) + } } #[cfg(test)] diff --git a/rwf/src/model/user.rs b/rwf/src/model/user.rs new file mode 100644 index 00000000..eff1d01c --- /dev/null +++ b/rwf/src/model/user.rs @@ -0,0 +1,97 @@ +use super::{Model, Pool, Row, ToValue, Value}; +use async_trait::async_trait; +use tokio::task::spawn_blocking; + +use thiserror::Error; + +/// User model error. +#[derive(Debug, Error)] +pub enum Error { + /// User already exists. + #[error("user already exists")] + UserExists, + + /// User doesn't exist. + #[error("user does not exist")] + UserDoesNotExist, + + /// Wrong password. + #[error("supplied password is incorrect")] + WrongPassword, + + /// Some database error. + #[error("{0}")] + DatabaseError(#[from] super::Error), +} + +/// Implement user creation and authentication for any database model +/// which has at least the identifier column and a password column. The identifier +/// column must have a unique index. +#[async_trait] +pub trait UserModel: Model + Sync { + fn identifier_column() -> &'static str; + fn password_column() -> &'static str; + + async fn create_user( + identifier: impl ToValue + Send, + password: impl ToString + Send, + ) -> Result { + let exists = Self::filter(Self::identifier_column(), identifier.to_value()) + .limit(1) + .fetch_optional(Pool::pool()) + .await?; + + if exists.is_some() { + return Err(Error::UserExists); + } + + let password = password.to_string(); + + let password_hash = spawn_blocking(move || crate::crypto::hash(password.as_bytes())) + .await + .unwrap() + .unwrap(); + + let user = Self::create(&[ + (Self::identifier_column(), identifier.to_value()), + (Self::password_column(), password_hash.to_value()), + ]) + .unique_by(&[Self::identifier_column()]) + .fetch(Pool::pool()) + .await?; + + Ok(user) + } + + async fn login_user( + identifier: impl ToValue + Send, + password: impl ToString + Send, + ) -> Result { + let user = Row::filter(Self::identifier_column(), identifier.to_value()) + .not(Self::password_column(), Value::Null) // Make sure column exists + .take_one() + .fetch_optional(Pool::pool()) + .await?; + + if let Some(user) = user { + let column: String = user.try_get(&Self::password_column()).unwrap(); + + let password = password.to_string(); + + let valid = + spawn_blocking(move || crate::crypto::hash_validate(column.as_bytes(), &password)) + .await + .unwrap() + .unwrap(); + + if valid { + let row = user.into_inner().unwrap(); + Ok(Self::from_row(row)?) + } else { + Err(Error::WrongPassword) + } + } else { + Err(Error::UserDoesNotExist) + } + } +} diff --git a/rwf/src/model/value.rs b/rwf/src/model/value.rs index 8d55c679..a1aa7ee5 100644 --- a/rwf/src/model/value.rs +++ b/rwf/src/model/value.rs @@ -114,6 +114,24 @@ impl Value { _ => false, } } + + /// Convert the value to an integer if it is one. + pub fn integer(self) -> Result { + match self { + Value::Int(i) => Ok(i as i64), + Value::Integer(i) => Ok(i), + Value::BigInt(i) => Ok(i), + Value::SmallInt(i) => Ok(i as i64), + Value::Optional(value) => match *value { + Some(Value::Int(i)) => Ok(i as i64), + Some(Value::Integer(i)) => Ok(i), + Some(Value::BigInt(i)) => Ok(i), + Some(Value::SmallInt(i)) => Ok(i as i64), + _ => Err(Error::NotAnInteger), + }, + _ => Err(Error::NotAnInteger), + } + } } /// Convert a Rust type to a [`Value`]. Implementation for many common types From 3fa4decd65e08054b7a521781ce353f654997fda Mon Sep 17 00:00:00 2001 From: Lev Kokotov Date: Fri, 6 Dec 2024 15:26:13 -0800 Subject: [PATCH 2/8] Users controller --- examples/users/src/controllers.rs | 51 ------------ examples/users/src/main.rs | 12 ++- examples/users/src/models.rs | 73 ----------------- examples/users/templates/login.html | 42 ++++++++++ examples/users/templates/signup.html | 14 +--- rwf/src/controller/mod.rs | 2 +- rwf/src/controller/user.rs | 117 ++++++++++++++++++++++++--- rwf/src/model/error.rs | 3 + rwf/src/model/mod.rs | 18 ++++- rwf/src/model/user.rs | 13 +-- rwf/src/model/value.rs | 8 ++ 11 files changed, 194 insertions(+), 159 deletions(-) create mode 100644 examples/users/templates/login.html diff --git a/examples/users/src/controllers.rs b/examples/users/src/controllers.rs index d864b705..f9258205 100644 --- a/examples/users/src/controllers.rs +++ b/examples/users/src/controllers.rs @@ -1,57 +1,6 @@ use crate::models::*; use rwf::prelude::*; -#[derive(macros::Form)] -struct SignupForm { - email: String, - password: String, -} - -#[derive(Default, macros::PageController)] -pub struct Signup; - -#[async_trait] -impl PageController for Signup { - async fn get(&self, request: &Request) -> Result { - let user = request.user::(Pool::pool()).await?; - - if let Some(_) = user { - return Ok(Response::new().redirect("/profile")); - } - - render!(request, "templates/signup.html") - } - - async fn post(&self, request: &Request) -> Result { - let form = request.form::()?; - let user = User::signup(&form.email, &form.password).await?; - - match user { - UserLogin::Ok(user) => Ok(request.login_user(&user)?.redirect("/profile")), - _ => render!(request, "templates/signup.html", "error" => true, 400), - } - } -} - -#[controller] -pub async fn login(request: &Request) -> Result { - let form = request.form::()?; - - let user = User::login(&form.email, &form.password).await?; - - if let UserLogin::Ok(_) = user { - Ok(Response::new().redirect("/profile")) - } else { - render!( - request, - "templates/signup.html", - "login" => true, - "error" => true, - 400 - ) - } -} - #[controller] pub async fn profile(request: &Request) -> Result { let user = { diff --git a/examples/users/src/main.rs b/examples/users/src/main.rs index 158c7b54..5d45d048 100644 --- a/examples/users/src/main.rs +++ b/examples/users/src/main.rs @@ -1,4 +1,4 @@ -use rwf::controller::LoginController; +use rwf::controller::{LoginController, LogoutController, SignupController}; use rwf::{http::Server, prelude::*}; mod controllers; @@ -8,12 +8,16 @@ mod models; async fn main() { Logger::init(); - let signup: LoginController = - LoginController::new("templates/signup.html").redirect("/profile"); + let signup: SignupController = + SignupController::new("templates/signup.html").redirect("/profile"); + + let login: LoginController = + LoginController::new("templates/login.html").redirect("/profile"); Server::new(vec![ route!("/signup" => { signup }), - route!("/login" => controllers::login), + route!("/login" => { login }), + route!("/logout" => { LogoutController::default().redirect("/signup") }), route!("/profile" => controllers::profile), ]) .launch() diff --git a/examples/users/src/models.rs b/examples/users/src/models.rs index e468dcc8..f86edd57 100644 --- a/examples/users/src/models.rs +++ b/examples/users/src/models.rs @@ -1,21 +1,4 @@ -// use rwf::model::Error; -use rwf::crypto::{hash, hash_validate}; use rwf::prelude::*; -use tokio::task::spawn_blocking; - -#[derive(Clone, macros::Model, macros::UserModel)] -#[user_model(email, password_hash)] -pub struct User2 { - id: Option, - email: String, - password_hash: String, -} - -pub enum UserLogin { - NoSuchUser, - WrongPassword, - Ok(User), -} #[derive(Clone, macros::Model, macros::UserModel)] #[user_model(email, password)] @@ -25,59 +8,3 @@ pub struct User { password: String, created_at: OffsetDateTime, } - -impl User { - /// Create new user with email and password. - pub async fn signup(email: &str, password: &str) -> Result { - let hash_password = password.to_owned(); - let encrypted_password = spawn_blocking(move || hash(hash_password.as_bytes())) - .await - .unwrap()?; - - match Self::login(email, password).await? { - UserLogin::Ok(user) => return Ok(UserLogin::Ok(user)), - UserLogin::WrongPassword => return Ok(UserLogin::WrongPassword), - _ => (), - } - - let user = User::create(&[ - ("email", email.to_value()), - ("password", encrypted_password.to_value()), - ]) - .fetch(Pool::pool()) - .await?; - - Ok(UserLogin::Ok(user)) - } - - /// Login user with email and password. - /// - /// Return a user if one exists and the passwords match. - /// Return `None` otherwise. - pub async fn login(email: &str, password: &str) -> Result { - if let Some(user) = User::filter("email", email) - .fetch_optional(Pool::pool()) - .await? - { - if hash_validate(password.as_bytes(), &user.password)? { - return Ok(UserLogin::Ok(user)); - } else { - return Ok(UserLogin::WrongPassword); - } - } - - Ok(UserLogin::NoSuchUser) - } -} - -#[cfg(test)] -mod test { - use super::*; - - #[tokio::test] - async fn test_user() { - Migrations::migrate().await.unwrap(); - let _user = User::signup("test@test.com", "password2").await.unwrap(); - let _user = User::login("test@test.com", "password2").await.unwrap(); - } -} diff --git a/examples/users/templates/login.html b/examples/users/templates/login.html new file mode 100644 index 00000000..335ab69c --- /dev/null +++ b/examples/users/templates/login.html @@ -0,0 +1,42 @@ + + + + <%% "templates/head.html" %> + + +
+

Login

+ + <%= csrf_token() %> + + <% if error_user_does_not_exist %> +
+ Account with this email doesn't exist or the password is incorrect. +
+ <% end %> + + <% if error_password %> +
+ Wrong password. +
+ <% end %> + +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + diff --git a/examples/users/templates/signup.html b/examples/users/templates/signup.html index b757dfdf..8b3bf170 100644 --- a/examples/users/templates/signup.html +++ b/examples/users/templates/signup.html @@ -5,32 +5,22 @@
+

Create account

<% if error_user_exists %>
- Account with this email already exists, and the password is incorrect. + Account with this email already exists.
<% end %> <%= csrf_token() %>
- <% if error_identifier %> -
- Provided email is not valid. -
- <% end %>
- - <% if error_password %> -
- Provided password is not valid. -
- <% end %>
diff --git a/rwf/src/controller/mod.rs b/rwf/src/controller/mod.rs index 7cd52fe6..b8ad3870 100644 --- a/rwf/src/controller/mod.rs +++ b/rwf/src/controller/mod.rs @@ -51,7 +51,7 @@ pub use error::Error; pub use middleware::{Middleware, MiddlewareHandler, MiddlewareSet, Outcome, RateLimiter}; pub use static_files::{CacheControl, StaticFiles}; pub use turbo_stream::TurboStream; -pub use user::LoginController; +pub use user::{LoginController, LogoutController, SignupController}; use super::http::{ websocket::{self, DataFrame}, diff --git a/rwf/src/controller/user.rs b/rwf/src/controller/user.rs index c0a68c92..235867de 100644 --- a/rwf/src/controller/user.rs +++ b/rwf/src/controller/user.rs @@ -8,31 +8,66 @@ use crate::{ }; use async_trait::async_trait; +/// Controller to log a user in. pub struct LoginController { redirect: Option, template: &'static str, + signup: bool, _marker: PhantomData, } +/// Controller to create user accounts. +pub struct SignupController(LoginController); + +impl SignupController { + /// Create new signup controller with the given template. + pub fn new(template: &'static str) -> Self { + Self(LoginController::::new(template).signup()) + } + + /// Redirect to this URL if account creation was successful. + pub fn redirect(mut self, redirect: impl ToString) -> Self { + self.0 = self.0.redirect(redirect); + self + } +} + +#[async_trait] +impl Controller for SignupController { + async fn handle(&self, request: &Request) -> Result { + PageController::handle(&self.0, request).await + } +} + impl LoginController { + /// Create new login controller with the provided template. pub fn new(template: &'static str) -> Self { Self { redirect: None, template, + signup: false, _marker: PhantomData, } } + /// Redirect on successful account creation to this URL. pub fn redirect(mut self, redirect: impl ToString) -> Self { self.redirect = Some(redirect.to_string()); self } + fn signup(mut self) -> Self { + self.signup = true; + self + } + fn error(&self, request: &Request, error: &str) -> Result { let template = Template::load(self.template)?; let mut ctx = Context::new(); + ctx.set(error, true)?; ctx.set("request", request.clone())?; + Ok(Response::new().html(template.render(&ctx)?).code(400)) } } @@ -41,38 +76,65 @@ impl LoginController { impl PageController for LoginController { async fn get(&self, request: &Request) -> Result { let mut ctx = Context::new(); + ctx.set("request", request.clone())?; + let template = Template::load(self.template)?; + Ok(Response::new().html(template.render(&ctx)?)) } async fn post(&self, request: &Request) -> Result { let form = request.form_data()?; + let identifier: String = match form.get_required("identifier") { Ok(field) => field, Err(_) => return self.error(request, "error_identifier"), }; let identifier = identifier.trim().to_string(); + let password: String = match form.get_required("password") { Ok(field) => field, Err(_) => return self.error(request, "error_password"), }; - match T::create_user(identifier, password).await { - Ok(user) => { - let id = user.id().integer()?; - let response = request.login(id); + if self.signup { + match T::create_user(identifier, password).await { + Ok(user) => { + let id = user.id().integer()?; + let response = request.login(id); + + if let Some(ref redirect) = self.redirect { + Ok(response.redirect(redirect)) + } else { + Ok(response) + } + } + Err(err) => match err { + UserError::UserExists => return self.error(request, "error_user_exists"), + err => return Err(err.into()), + }, + } + } else { + match T::login_user(identifier, password).await { + Ok(user) => { + let id = user.id().integer()?; + let response = request.login(id); - if let Some(ref redirect) = self.redirect { - Ok(response.redirect(redirect)) - } else { - Ok(response) + if let Some(ref redirect) = self.redirect { + Ok(response.redirect(redirect)) + } else { + Ok(response) + } } + Err(err) => match err { + UserError::UserDoesNotExist => { + return self.error(request, "error_user_does_not_exist") + } + UserError::WrongPassword => return self.error(request, "error_password"), + err => return Err(err.into()), + }, } - Err(err) => match err { - UserError::UserExists => return self.error(request, "error_user_exists"), - err => return Err(err.into()), - }, } } } @@ -83,3 +145,34 @@ impl Controller for LoginController { PageController::handle(self, request).await } } + +/// Controller to log the user out. +#[derive(Default)] +pub struct LogoutController { + redirect: Option, +} + +impl LogoutController { + /// Create new logout controller. + pub fn new() -> Self { + Self { redirect: None } + } + + /// Redirect to this URL after logging the user out. + pub fn redirect(mut self, redirect: impl ToString) -> Self { + self.redirect = Some(redirect.to_string()); + self + } +} + +#[async_trait] +impl Controller for LogoutController { + async fn handle(&self, request: &Request) -> Result { + let response = request.logout(); + if let Some(ref redirect) = self.redirect { + Ok(response.redirect(redirect)) + } else { + Ok(response) + } + } +} diff --git a/rwf/src/model/error.rs b/rwf/src/model/error.rs index 495e9584..028939da 100644 --- a/rwf/src/model/error.rs +++ b/rwf/src/model/error.rs @@ -50,6 +50,9 @@ pub enum Error { #[error("value is not an integer")] NotAnInteger, + + #[error("value is not a string")] + NotAString, } impl Error { diff --git a/rwf/src/model/mod.rs b/rwf/src/model/mod.rs index 7369d8bf..45f8f23c 100644 --- a/rwf/src/model/mod.rs +++ b/rwf/src/model/mod.rs @@ -5,7 +5,10 @@ use crate::colors::MaybeColorize; use crate::config::get_config; use pool::ToConnectionRequest; -use std::time::{Duration, Instant}; +use std::{ + collections::HashMap, + time::{Duration, Instant}, +}; use tracing::{error, info}; pub mod callbacks; @@ -1458,6 +1461,19 @@ pub trait Model: FromRow { Ok(serde_json::Value::Object(map)) } + + /// Concert model to a column names -> values map. + fn to_hashmap(&self) -> HashMap { + let columns = Self::column_names(); + let values = self.values(); + let mut result = HashMap::new(); + + for (column, value) in columns.iter().zip(values.iter()) { + result.insert(column.to_string(), value.clone()); + } + + result + } } #[cfg(test)] diff --git a/rwf/src/model/user.rs b/rwf/src/model/user.rs index eff1d01c..bf790513 100644 --- a/rwf/src/model/user.rs +++ b/rwf/src/model/user.rs @@ -67,26 +67,29 @@ pub trait UserModel: Model + Sync { identifier: impl ToValue + Send, password: impl ToString + Send, ) -> Result { - let user = Row::filter(Self::identifier_column(), identifier.to_value()) + let user = Self::filter(Self::identifier_column(), identifier.to_value()) .not(Self::password_column(), Value::Null) // Make sure column exists .take_one() .fetch_optional(Pool::pool()) .await?; if let Some(user) = user { - let column: String = user.try_get(&Self::password_column()).unwrap(); + let key_values = user.to_hashmap(); + let column: String = key_values + .get(Self::password_column()) + .map(|v| v.clone().string().unwrap()) + .unwrap(); let password = password.to_string(); let valid = - spawn_blocking(move || crate::crypto::hash_validate(column.as_bytes(), &password)) + spawn_blocking(move || crate::crypto::hash_validate(password.as_bytes(), &column)) .await .unwrap() .unwrap(); if valid { - let row = user.into_inner().unwrap(); - Ok(Self::from_row(row)?) + Ok(user) } else { Err(Error::WrongPassword) } diff --git a/rwf/src/model/value.rs b/rwf/src/model/value.rs index a1aa7ee5..518465c0 100644 --- a/rwf/src/model/value.rs +++ b/rwf/src/model/value.rs @@ -132,6 +132,14 @@ impl Value { _ => Err(Error::NotAnInteger), } } + + /// Convert the value to a string. + pub fn string(self) -> Result { + match self { + Value::String(val) => Ok(val), + _ => Err(Error::NotAString), + } + } } /// Convert a Rust type to a [`Value`]. Implementation for many common types From f686dcc0a5e53126412bbca6e303b0018ea43f48 Mon Sep 17 00:00:00 2001 From: Lev Kokotov Date: Fri, 6 Dec 2024 15:58:14 -0800 Subject: [PATCH 3/8] save --- examples/users/templates/login.html | 4 ++-- examples/users/templates/signup.html | 9 +++++++-- rwf/src/model/user.rs | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/examples/users/templates/login.html b/examples/users/templates/login.html index 335ab69c..63503d1e 100644 --- a/examples/users/templates/login.html +++ b/examples/users/templates/login.html @@ -4,7 +4,7 @@ <%% "templates/head.html" %> -
+

Login

<%= csrf_token() %> @@ -33,7 +33,7 @@

Login

diff --git a/examples/users/templates/signup.html b/examples/users/templates/signup.html index 8b3bf170..0b372475 100644 --- a/examples/users/templates/signup.html +++ b/examples/users/templates/signup.html @@ -4,7 +4,7 @@ <%% "templates/head.html" %> -
+

Create account

<% if error_user_exists %> @@ -23,9 +23,14 @@

Create account

+
+ + +
+
diff --git a/rwf/src/model/user.rs b/rwf/src/model/user.rs index bf790513..35609d7e 100644 --- a/rwf/src/model/user.rs +++ b/rwf/src/model/user.rs @@ -1,4 +1,4 @@ -use super::{Model, Pool, Row, ToValue, Value}; +use super::{Model, Pool, ToValue, Value}; use async_trait::async_trait; use tokio::task::spawn_blocking; From ee4eb92791f43fa94b8e7d438f607a013edcb397 Mon Sep 17 00:00:00 2001 From: Lev Kokotov Date: Sat, 7 Dec 2024 08:59:24 -0800 Subject: [PATCH 4/8] save --- Cargo.lock | 7 ++ Cargo.toml | 2 +- rwf-auth/Cargo.toml | 7 ++ rwf-auth/migrations/.gitkeep | 0 rwf-auth/src/controllers/mod.rs | 3 + rwf-auth/src/controllers/password.rs | 101 +++++++++++++++++++++++++++ rwf-auth/src/lib.rs | 2 + rwf-auth/src/models/mod.rs | 0 rwf-auth/static/.gitkeep | 0 rwf-auth/templates/.gitkeep | 0 rwf-macros/src/render.rs | 8 +-- 11 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 rwf-auth/Cargo.toml create mode 100644 rwf-auth/migrations/.gitkeep create mode 100644 rwf-auth/src/controllers/mod.rs create mode 100644 rwf-auth/src/controllers/password.rs create mode 100644 rwf-auth/src/lib.rs create mode 100644 rwf-auth/src/models/mod.rs create mode 100644 rwf-auth/static/.gitkeep create mode 100644 rwf-auth/templates/.gitkeep diff --git a/Cargo.lock b/Cargo.lock index 40a404dd..c93c6c08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1973,6 +1973,13 @@ dependencies = [ "uuid", ] +[[package]] +name = "rwf-auth" +version = "0.1.0" +dependencies = [ + "rwf", +] + [[package]] name = "rwf-cli" version = "0.1.14" diff --git a/Cargo.toml b/Cargo.toml index 0fc48ae4..eb140543 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,6 @@ members = [ "examples/request-tracking", "examples/engine", "rwf-admin", - "examples/files", "examples/users", + "examples/files", "examples/users", "rwf-auth", ] exclude = ["examples/rails", "rwf-ruby", "examples/django", "rwf-fuzz"] diff --git a/rwf-auth/Cargo.toml b/rwf-auth/Cargo.toml new file mode 100644 index 00000000..264f6d13 --- /dev/null +++ b/rwf-auth/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "rwf-auth" +version = "0.1.0" +edition = "2021" + +[dependencies] +rwf = { path = "../rwf", version = "0.2.1" } diff --git a/rwf-auth/migrations/.gitkeep b/rwf-auth/migrations/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/rwf-auth/src/controllers/mod.rs b/rwf-auth/src/controllers/mod.rs new file mode 100644 index 00000000..322af0fe --- /dev/null +++ b/rwf-auth/src/controllers/mod.rs @@ -0,0 +1,3 @@ +// This file is automatically generated by rwf-cli. +// Manual modifications to this file will not be preserved. +pub mod password; \ No newline at end of file diff --git a/rwf-auth/src/controllers/password.rs b/rwf-auth/src/controllers/password.rs new file mode 100644 index 00000000..40ffa1f0 --- /dev/null +++ b/rwf-auth/src/controllers/password.rs @@ -0,0 +1,101 @@ +use std::marker::PhantomData; + +use rwf::{ + model::{user::Error as UserError, UserModel}, + prelude::*, +}; + +#[derive(macros::Form)] +struct PasswordForm { + identifier: String, + password: String, +} + +/// Errors passed to the template. +#[derive(macros::Context, Default)] +pub struct Errors { + /// Something was wrong with the identifier. + pub error_identifier: bool, + /// Password was incorrect or the user didn't exist. + pub error_password: bool, +} + +impl Errors { + fn form() -> Self { + let mut ctx = Self::default(); + ctx.error_identifier = true; + ctx.error_password = true; + ctx + } + + fn wrong_password() -> Self { + let mut ctx = Self::default(); + ctx.error_password = true; + ctx + } +} + +#[derive(Default)] +pub struct Password { + template_path: String, + redirect_url: String, + _marker: PhantomData, +} + +impl Password { + pub fn template(template_path: &str) -> Self { + Self { + template_path: template_path.to_owned(), + redirect_url: "/".into(), + _marker: PhantomData, + } + } + + pub fn redirect(mut self, redirect_url: &str) -> Self { + self.redirect_url = redirect_url.to_owned(); + self + } +} + +#[async_trait] +impl Controller for Password { + async fn handle(&self, request: &Request) -> Result { + PageController::handle(self, request).await + } +} + +#[async_trait] +impl PageController for Password { + async fn get(&self, request: &Request) -> Result { + render!(request, &self.template_path) + } + + async fn post(&self, request: &Request) -> Result { + let tpl = Template::load(&self.template_path)?; + + let form = if let Ok(form) = request.form::() { + form + } else { + return Ok(Response::new().html(tpl.render(Errors::form())?).code(400)); + }; + + let user = match T::create_user(&form.identifier, &form.password).await { + Ok(user) => user, + Err(UserError::UserExists) => { + match T::login_user(&form.identifier, &form.password).await { + Ok(user) => user, + Err(UserError::WrongPassword) => { + return Ok(Response::new() + .html(tpl.render(Errors::wrong_password())?) + .code(400)) + } + Err(err) => return Err(err.into()), + } + } + + Err(err) => return Err(err.into()), + }; + + Ok(request.login_user(&user)?.redirect(&self.redirect_url)) + } +} diff --git a/rwf-auth/src/lib.rs b/rwf-auth/src/lib.rs new file mode 100644 index 00000000..3619c212 --- /dev/null +++ b/rwf-auth/src/lib.rs @@ -0,0 +1,2 @@ +pub mod controllers; +pub mod models; diff --git a/rwf-auth/src/models/mod.rs b/rwf-auth/src/models/mod.rs new file mode 100644 index 00000000..e69de29b diff --git a/rwf-auth/static/.gitkeep b/rwf-auth/static/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/rwf-auth/templates/.gitkeep b/rwf-auth/templates/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/rwf-macros/src/render.rs b/rwf-macros/src/render.rs index 36c89c6d..970949f3 100644 --- a/rwf-macros/src/render.rs +++ b/rwf-macros/src/render.rs @@ -3,7 +3,7 @@ use crate::prelude::*; struct RenderInput { request: Expr, _comma_0: Token![,], - template_name: LitStr, + template_name: Expr, _comma_1: Option, context: Vec, code: Option, @@ -13,7 +13,7 @@ struct RenderInput { struct TurboStreamInput { request: Expr, _comma_0: Token![,], - template_name: LitStr, + template_name: Expr, _comma_1: Token![,], id: Expr, _comma_2: Option, @@ -38,7 +38,7 @@ impl Parse for TurboStreamInput { fn parse(input: ParseStream) -> Result { let request: Expr = input.parse()?; let _comma_0: Token![,] = input.parse()?; - let template_name: LitStr = input.parse()?; + let template_name: Expr = input.parse()?; let _comma_1: Token![,] = input.parse()?; let id: Expr = input.parse()?; let _comma_2: Option = input.parse()?; @@ -116,7 +116,7 @@ impl Parse for RenderInput { fn parse(input: ParseStream) -> Result { let request: Expr = input.parse()?; let _comma_0: Token![,] = input.parse()?; - let template_name: LitStr = input.parse()?; + let template_name: Expr = input.parse()?; let _comma_1: Option = input.parse()?; let mut code = None; let mut _comma_2 = None; From 7386258b37cc029c3d4567fa7a58e013ece4b9d5 Mon Sep 17 00:00:00 2001 From: Lev Kokotov Date: Mon, 9 Dec 2024 12:02:56 -0800 Subject: [PATCH 5/8] rwf-auth migrations --- Cargo.lock | 2 + examples/users/Cargo.toml | 1 + examples/users/src/main.rs | 1 + rwf-auth/Cargo.toml | 1 + ...733765331409957000_rwf_auth_users.down.sql | 1 + .../1733765331409957000_rwf_auth_users.up.sql | 8 +++ rwf-auth/src/controllers/mod.rs | 4 +- rwf-auth/src/controllers/password.rs | 52 ++++++++++++++-- rwf-auth/src/lib.rs | 10 ++++ rwf-auth/src/models/mod.rs | 10 ++++ rwf-cli/Cargo.toml | 1 + rwf-cli/src/migrate.rs | 18 +++++- rwf-cli/src/setup.rs | 9 --- rwf-cli/src/util.rs | 12 ++++ rwf-macros/src/user.rs | 28 ++++----- rwf-tests/src/main.rs | 2 +- rwf/src/model/migrations/mod.rs | 59 ++++++++++++++----- 17 files changed, 171 insertions(+), 48 deletions(-) create mode 100644 rwf-auth/migrations/1733765331409957000_rwf_auth_users.down.sql create mode 100644 rwf-auth/migrations/1733765331409957000_rwf_auth_users.up.sql diff --git a/Cargo.lock b/Cargo.lock index c93c6c08..507d735f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1989,6 +1989,7 @@ dependencies = [ "log", "regex", "rwf", + "rwf-auth", "serde_json", "tar", "time", @@ -2691,6 +2692,7 @@ version = "0.1.0" dependencies = [ "argon2", "rwf", + "rwf-auth", "time", ] diff --git a/examples/users/Cargo.toml b/examples/users/Cargo.toml index c36eec7b..cd4d5d26 100644 --- a/examples/users/Cargo.toml +++ b/examples/users/Cargo.toml @@ -7,3 +7,4 @@ edition = "2021" rwf = { path = "../../rwf" } time = "0.3" argon2 = "0.5" +rwf-auth = { path = "../../rwf-auth" } diff --git a/examples/users/src/main.rs b/examples/users/src/main.rs index 5d45d048..1ecd158b 100644 --- a/examples/users/src/main.rs +++ b/examples/users/src/main.rs @@ -7,6 +7,7 @@ mod models; #[tokio::main] async fn main() { Logger::init(); + rwf_auth::migrate().await.expect("rwf-auth migrations"); let signup: SignupController = SignupController::new("templates/signup.html").redirect("/profile"); diff --git a/rwf-auth/Cargo.toml b/rwf-auth/Cargo.toml index 264f6d13..7da7f644 100644 --- a/rwf-auth/Cargo.toml +++ b/rwf-auth/Cargo.toml @@ -2,6 +2,7 @@ name = "rwf-auth" version = "0.1.0" edition = "2021" +include = ["migrations/", "src/", "static/", "templates/"] [dependencies] rwf = { path = "../rwf", version = "0.2.1" } diff --git a/rwf-auth/migrations/1733765331409957000_rwf_auth_users.down.sql b/rwf-auth/migrations/1733765331409957000_rwf_auth_users.down.sql new file mode 100644 index 00000000..f400692c --- /dev/null +++ b/rwf-auth/migrations/1733765331409957000_rwf_auth_users.down.sql @@ -0,0 +1 @@ +DROP TABLE rwf_auth_users; diff --git a/rwf-auth/migrations/1733765331409957000_rwf_auth_users.up.sql b/rwf-auth/migrations/1733765331409957000_rwf_auth_users.up.sql new file mode 100644 index 00000000..0519a7c0 --- /dev/null +++ b/rwf-auth/migrations/1733765331409957000_rwf_auth_users.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE rwf_auth_users ( + id BIGSERIAL PRIMARY KEY, + identifier VARCHAR NOT NULL UNIQUE, + password VARCHAR NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW () +); + +CREATE INDEX ON rwf_auth_users USING btree (created_at); diff --git a/rwf-auth/src/controllers/mod.rs b/rwf-auth/src/controllers/mod.rs index 322af0fe..ec0c9899 100644 --- a/rwf-auth/src/controllers/mod.rs +++ b/rwf-auth/src/controllers/mod.rs @@ -1,3 +1,5 @@ // This file is automatically generated by rwf-cli. // Manual modifications to this file will not be preserved. -pub mod password; \ No newline at end of file +pub mod password; + +pub use password::{Password, PasswordController}; diff --git a/rwf-auth/src/controllers/password.rs b/rwf-auth/src/controllers/password.rs index 40ffa1f0..09dfdc16 100644 --- a/rwf-auth/src/controllers/password.rs +++ b/rwf-auth/src/controllers/password.rs @@ -1,3 +1,14 @@ +//! Password authentication controller. +//! +//! Create an account if one doesn't exist. If one exists, attempt to log in. +//! +//! ### Errors +//! +//! The following errors are set in the template: +//! +//! - `error_form`: Any of the required fields are missing. +//! - `error_password`: Account exists and the password is incorrect. +//! - `error_password2`: Passwords do not match on account creation. Only set if `` is present in the form. use std::marker::PhantomData; use rwf::{ @@ -5,26 +16,33 @@ use rwf::{ prelude::*, }; +use crate::models::User; + +/// Account creation and login form. #[derive(macros::Form)] -struct PasswordForm { +pub struct PasswordForm { identifier: String, password: String, + password2: Option, } -/// Errors passed to the template. +/// Password errors. +/// +/// These are passed to the template in the context. #[derive(macros::Context, Default)] pub struct Errors { - /// Something was wrong with the identifier. - pub error_identifier: bool, + /// Form has missing fields. + pub error_form: bool, /// Password was incorrect or the user didn't exist. pub error_password: bool, + /// Passwords do not match. + pub error_password2: bool, } impl Errors { fn form() -> Self { let mut ctx = Self::default(); - ctx.error_identifier = true; - ctx.error_password = true; + ctx.error_form = true; ctx } @@ -33,8 +51,16 @@ impl Errors { ctx.error_password = true; ctx } + + fn wrong_password_match() -> Self { + let mut ctx = Self::default(); + ctx.error_password2 = true; + ctx + } } +/// Generic password authentication controller. Can be used with any model +/// which implements the [`rwf::model::UserModel`] trait. #[derive(Default)] pub struct Password { template_path: String, @@ -43,6 +69,7 @@ pub struct Password { } impl Password { + /// Create controller with the specified template path. pub fn template(template_path: &str) -> Self { Self { template_path: template_path.to_owned(), @@ -51,6 +78,7 @@ impl Password { } } + /// Redirect to the specified URL on successful authentication. pub fn redirect(mut self, redirect_url: &str) -> Self { self.redirect_url = redirect_url.to_owned(); self @@ -79,6 +107,15 @@ impl PageController for Password { return Ok(Response::new().html(tpl.render(Errors::form())?).code(400)); }; + // If second password passed in, make sure they match. + if let Some(ref password2) = form.password2 { + if password2 != &form.password { + return Ok(Response::new() + .html(tpl.render(Errors::wrong_password_match())?) + .code(400)); + } + } + let user = match T::create_user(&form.identifier, &form.password).await { Ok(user) => user, Err(UserError::UserExists) => { @@ -99,3 +136,6 @@ impl PageController for Password { Ok(request.login_user(&user)?.redirect(&self.redirect_url)) } } + +/// Password controller implemented for the [`rwf_auth::models::User`] model. +pub type PasswordController = Password; diff --git a/rwf-auth/src/lib.rs b/rwf-auth/src/lib.rs index 3619c212..08dfe017 100644 --- a/rwf-auth/src/lib.rs +++ b/rwf-auth/src/lib.rs @@ -1,2 +1,12 @@ +use std::path::PathBuf; + +use rwf::model::{Error, Migrations}; + pub mod controllers; pub mod models; + +/// Run `rwf-auth` migrations. +pub async fn migrate() -> Result { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + rwf::model::migrate(Some(path)).await +} diff --git a/rwf-auth/src/models/mod.rs b/rwf-auth/src/models/mod.rs index e69de29b..4e311908 100644 --- a/rwf-auth/src/models/mod.rs +++ b/rwf-auth/src/models/mod.rs @@ -0,0 +1,10 @@ +use rwf::prelude::*; + +#[derive(Clone, macros::Model, macros::UserModel, Debug)] +#[table_name("rwf_auth_users")] +pub struct User { + id: Option, + identifier: String, + password: String, + created_at: OffsetDateTime, +} diff --git a/rwf-cli/Cargo.toml b/rwf-cli/Cargo.toml index 862b9d85..09c53e31 100644 --- a/rwf-cli/Cargo.toml +++ b/rwf-cli/Cargo.toml @@ -11,6 +11,7 @@ readme = "README.md" [dependencies] clap = { version = "4.5.18", features = ["derive"] } rwf = { path = "../rwf", version = "0.2" } +rwf-auth = { path = "../rwf-auth", version = "0.1.0" } tokio = { version = "1", features = ["full"] } log = "0.4" time = "0.3" diff --git a/rwf-cli/src/migrate.rs b/rwf-cli/src/migrate.rs index d9738137..e5595771 100644 --- a/rwf-cli/src/migrate.rs +++ b/rwf-cli/src/migrate.rs @@ -5,10 +5,20 @@ use time::OffsetDateTime; use regex::Regex; use tokio::fs::{create_dir, File}; -use crate::logging::created; +use crate::{logging::created, util::package_info}; pub async fn migrate(version: Option) { - let migrations = Migrations::sync().await.expect("failed to sync migrations"); + let info = package_info().await.expect("couldn't get package info"); + + if info.rwf_auth { + rwf_auth::migrate() + .await + .expect("rwf-auth migrations failed to apply"); + } + + let migrations = Migrations::sync(None) + .await + .expect("failed to sync migrations"); migrations .apply(Direction::Up, version) @@ -17,7 +27,9 @@ pub async fn migrate(version: Option) { } pub async fn revert(version: Option) { - let migrations = Migrations::sync().await.expect("failed to sync migrations"); + let migrations = Migrations::sync(None) + .await + .expect("failed to sync migrations"); let version = if let Some(version) = version { Some(version) } else { diff --git a/rwf-cli/src/setup.rs b/rwf-cli/src/setup.rs index de233c92..8d9e3f4d 100644 --- a/rwf-cli/src/setup.rs +++ b/rwf-cli/src/setup.rs @@ -71,15 +71,6 @@ pub async fn setup() { } // Add rwf dependencies - Command::new("cargo") - .arg("add") - .arg("tokio@1") - .arg("--features") - .arg("full") - .status() - .await - .unwrap(); - Command::new("cargo") .arg("add") .arg("rwf") diff --git a/rwf-cli/src/util.rs b/rwf-cli/src/util.rs index 8f2001ba..e44d6bfb 100644 --- a/rwf-cli/src/util.rs +++ b/rwf-cli/src/util.rs @@ -8,6 +8,7 @@ pub struct PackageInfo { #[allow(dead_code)] pub version: String, pub target_dir: String, + pub rwf_auth: bool, } async fn cargo_toml() -> Result> { @@ -32,6 +33,16 @@ pub async fn package_info() -> Result Result TokenStream { if let Some(attr) = input.attrs.first() { match attr.meta { Meta::List(ref attrs) => { - let attrs = syn::parse2::(attrs.tokens.clone()).unwrap(); - - let identifier = attrs.identifier.to_string(); - let password = attrs.password.to_string(); - - return quote! { - impl rwf::model::UserModel for #ident { - fn identifier_column() -> &'static str { - #identifier - } - - fn password_column() -> &'static str { - #password + if let Ok(attrs) = syn::parse2::(attrs.tokens.clone()) { + let identifier = attrs.identifier.to_string(); + let password = attrs.password.to_string(); + + return quote! { + impl rwf::model::UserModel for #ident { + fn identifier_column() -> &'static str { + #identifier + } + + fn password_column() -> &'static str { + #password + } } } + .into(); } - .into(); } _ => (), diff --git a/rwf-tests/src/main.rs b/rwf-tests/src/main.rs index 7ad34dba..cab43c61 100644 --- a/rwf-tests/src/main.rs +++ b/rwf-tests/src/main.rs @@ -225,7 +225,7 @@ async fn main() -> Result<(), Box> { .init(); rollback().await?; - migrate().await?; + migrate(None).await?; let pool = Pool::from_env(); let mut conn = pool.get().await?; diff --git a/rwf/src/model/migrations/mod.rs b/rwf/src/model/migrations/mod.rs index 049e8398..fbbd503d 100644 --- a/rwf/src/model/migrations/mod.rs +++ b/rwf/src/model/migrations/mod.rs @@ -20,6 +20,7 @@ use tracing::{error, info}; /// may not be applied yet. pub struct Migrations { migrations: Vec, + root_path: Option, } static RE: Lazy = @@ -99,8 +100,15 @@ impl MigrationFile { } impl Migrations { - fn root_path() -> Result { - let path = PathBuf::from(current_dir()?.join(Path::new("migrations"))); + fn root_path(path: Option) -> Result { + let path = PathBuf::from( + if let Some(path) = path { + path + } else { + current_dir()? + } + .join(Path::new("migrations")), + ); if !path.is_dir() { info!(r#"No migrations available, skipping"#); @@ -112,19 +120,22 @@ impl Migrations { } } - async fn load() -> Result { + async fn load(root_path: Option) -> Result { let mut conn = get_connection().await?; let migrations = Migration::all().fetch_all(&mut conn).await?; - Ok(Self { migrations }) + Ok(Self { + migrations, + root_path, + }) } /// Read the `"migrations"` folder and sync all migrations /// to the `"rwf_migrations"` table in the database. This does not /// actually apply the migrations, only makes sure the entries in the folder /// match the database table. - pub async fn sync() -> Result { - let checks = if let Ok(root_path) = Self::root_path() { + pub async fn sync(root_path: Option) -> Result { + let checks = if let Ok(root_path) = Self::root_path(root_path.clone()) { let mut checks = HashMap::new(); let mut dir_entries = read_dir(root_path).await?; @@ -199,7 +210,10 @@ impl Migrations { conn.commit().await?; - Ok(Self { migrations }) + Ok(Self { + migrations, + root_path, + }) } /// Apply the migrations, making changes to the database schema. @@ -240,7 +254,7 @@ impl Migrations { migration.name() ); - let path = Self::root_path()?.join(migration.path(direction)); + let path = Self::root_path(self.root_path.clone())?.join(migration.path(direction)); let sql = read_to_string(path).await?; let queries = sql @@ -291,7 +305,7 @@ impl Migrations { .await?; } - Self::load().await + Self::load(self.root_path).await } /// Get a list of all migrations currently found in the `"migrations"` folder. @@ -301,25 +315,42 @@ impl Migrations { /// Execute all migrations in the up direction. pub async fn migrate() -> Result { - Migrations::sync().await?.apply(Direction::Up, None).await + Migrations::sync(None) + .await? + .apply(Direction::Up, None) + .await } /// Execute all migrations in the down direction. **This will effectively /// destroy all tables and data in your database.** pub async fn flush() -> Result { - Migrations::sync().await?.apply(Direction::Down, None).await + Migrations::sync(None) + .await? + .apply(Direction::Down, None) + .await } } /// Execute all migrations in the up direction. -pub async fn migrate() -> Result { - Migrations::sync().await?.apply(Direction::Up, None).await +/// +/// # Arguments +/// +/// - `root_path`: Folder where the `migrations` folder is located. +/// +pub async fn migrate(root_path: Option) -> Result { + Migrations::sync(root_path) + .await? + .apply(Direction::Up, None) + .await } /// Execute all migrations in the down direction. **This will effectively /// destroy all tables and data in your database.** pub async fn rollback() -> Result { - Migrations::sync().await?.apply(Direction::Down, None).await + Migrations::sync(None) + .await? + .apply(Direction::Down, None) + .await } #[cfg(test)] From 31c623df5af8a6c57c4dfbf1577c6f53576c1499 Mon Sep 17 00:00:00 2001 From: Lev Kokotov Date: Mon, 9 Dec 2024 13:40:03 -0800 Subject: [PATCH 6/8] migrations everywhere! --- Cargo.lock | 2 +- examples/users/migrations/.gitkeep | 0 ...733765331409957000_rwf_auth_users.down.sql | 1 + .../1733765331409957000_rwf_auth_users.up.sql | 8 +++ .../1733778837089567000_rwf_init.down.sql | 3 ++ .../1733778837089567000_rwf_init.up.sql | 45 ++++++++++++++++ examples/users/rwf.toml | 2 +- rwf-auth/src/lib.rs | 4 ++ rwf-cli/Cargo.toml | 2 +- rwf-cli/src/migrate.rs | 11 +++- rwf/Cargo.toml | 1 + .../1733778837089567000_rwf_init.down.sql | 3 ++ .../1733778837089567000_rwf_init.up.sql | 45 ++++++++++++++++ rwf/src/lib.rs | 7 ++- rwf/src/model/migrations/bootstrap.sql | 54 ++----------------- rwf/src/model/migrations/mod.rs | 18 ++++++- 16 files changed, 149 insertions(+), 57 deletions(-) create mode 100644 examples/users/migrations/.gitkeep create mode 100644 examples/users/migrations/1733765331409957000_rwf_auth_users.down.sql create mode 100644 examples/users/migrations/1733765331409957000_rwf_auth_users.up.sql create mode 100644 examples/users/migrations/1733778837089567000_rwf_init.down.sql create mode 100644 examples/users/migrations/1733778837089567000_rwf_init.up.sql create mode 100644 rwf/migrations/1733778837089567000_rwf_init.down.sql create mode 100644 rwf/migrations/1733778837089567000_rwf_init.up.sql diff --git a/Cargo.lock b/Cargo.lock index 507d735f..84c06197 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1982,7 +1982,7 @@ dependencies = [ [[package]] name = "rwf-cli" -version = "0.1.14" +version = "0.2.2" dependencies = [ "clap", "flate2", diff --git a/examples/users/migrations/.gitkeep b/examples/users/migrations/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/examples/users/migrations/1733765331409957000_rwf_auth_users.down.sql b/examples/users/migrations/1733765331409957000_rwf_auth_users.down.sql new file mode 100644 index 00000000..f400692c --- /dev/null +++ b/examples/users/migrations/1733765331409957000_rwf_auth_users.down.sql @@ -0,0 +1 @@ +DROP TABLE rwf_auth_users; diff --git a/examples/users/migrations/1733765331409957000_rwf_auth_users.up.sql b/examples/users/migrations/1733765331409957000_rwf_auth_users.up.sql new file mode 100644 index 00000000..0519a7c0 --- /dev/null +++ b/examples/users/migrations/1733765331409957000_rwf_auth_users.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE rwf_auth_users ( + id BIGSERIAL PRIMARY KEY, + identifier VARCHAR NOT NULL UNIQUE, + password VARCHAR NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW () +); + +CREATE INDEX ON rwf_auth_users USING btree (created_at); diff --git a/examples/users/migrations/1733778837089567000_rwf_init.down.sql b/examples/users/migrations/1733778837089567000_rwf_init.down.sql new file mode 100644 index 00000000..0fa189a2 --- /dev/null +++ b/examples/users/migrations/1733778837089567000_rwf_init.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS rwf_requests; + +DROP TABLE IF EXISTS rwf_jobs; diff --git a/examples/users/migrations/1733778837089567000_rwf_init.up.sql b/examples/users/migrations/1733778837089567000_rwf_init.up.sql new file mode 100644 index 00000000..583b0b6d --- /dev/null +++ b/examples/users/migrations/1733778837089567000_rwf_init.up.sql @@ -0,0 +1,45 @@ + +CREATE TABLE IF NOT EXISTS rwf_jobs ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR NOT NULL, + args JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + start_after TIMESTAMPTZ NOT NULL DEFAULT NOW(), + started_at TIMESTAMPTZ, + attempts INT NOT NULL DEFAULT 0, + retries BIGINT NOT NULL DEFAULT 25, + completed_at TIMESTAMPTZ, + error VARCHAR +); + +-- Pending jobs +CREATE INDEX IF NOT EXISTS rwf_jobs_pending_idx ON rwf_jobs USING btree(start_after, created_at) WHERE + completed_at IS NULL + AND started_at IS NULL + AND attempts < retries; + +-- Running jobs +CREATE INDEX IF NOT EXISTS rwf_jobs_runnin_idx ON rwf_jobs USING btree(start_after, created_at) WHERE + completed_at IS NULL + AND started_at IS NOT NULL + AND attempts < retries; + +CREATE INDEX IF NOT EXISTS rwf_jobs_name_completed_at_idx ON rwf_jobs USING btree(name, completed_at); + +CREATE TABLE IF NOT EXISTS rwf_requests ( + id BIGSERIAL PRIMARY KEY, + path VARCHAR NOT NULL, + method VARCHAR NOT NULL DEFAULT 'GET', + query JSONB NOT NULL DEFAULT '{}'::jsonb, + code INTEGER NOT NULL DEFAULT 200, + client_ip INET, + client_id UUID NOT NULL DEFAULT gen_random_uuid(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + duration REAL NOT NULL +); + +CREATE INDEX IF NOT EXISTS rwf_requests_path_created_at ON rwf_requests USING btree(created_at, path, client_id); + +CREATE INDEX IF NOT EXISTS rwf_requests_errors ON rwf_requests USING btree(created_at, code, client_id) WHERE code >= 400; + +CREATE INDEX IF NOT EXISTS rwf_requests_too_slow ON rwf_requests USING btree(created_at, duration, client_id) WHERE duration >= 1000.0; -- the unit is milliseconds diff --git a/examples/users/rwf.toml b/examples/users/rwf.toml index 299aa839..a8536eb9 100644 --- a/examples/users/rwf.toml +++ b/examples/users/rwf.toml @@ -1,6 +1,6 @@ [general] secret_key = "9Sk2t2G40QC3QrdVr6e6RzYAKJLGTFjpDiRrmA7eGQk=" -log_queries = true +# log_queries = true [database] name = "rwf_users" diff --git a/rwf-auth/src/lib.rs b/rwf-auth/src/lib.rs index 08dfe017..ec27330e 100644 --- a/rwf-auth/src/lib.rs +++ b/rwf-auth/src/lib.rs @@ -10,3 +10,7 @@ pub async fn migrate() -> Result { let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); rwf::model::migrate(Some(path)).await } + +pub fn migrations_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("migrations") +} diff --git a/rwf-cli/Cargo.toml b/rwf-cli/Cargo.toml index 09c53e31..090a25fb 100644 --- a/rwf-cli/Cargo.toml +++ b/rwf-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rwf-cli" -version = "0.1.14" +version = "0.2.2" edition = "2021" license = "MIT" description = "Rust Web Framework CLI" diff --git a/rwf-cli/src/migrate.rs b/rwf-cli/src/migrate.rs index e5595771..1d6e927c 100644 --- a/rwf-cli/src/migrate.rs +++ b/rwf-cli/src/migrate.rs @@ -2,6 +2,7 @@ use rwf::model::migrations::{Direction, Migrations}; use std::path::Path; use time::OffsetDateTime; +use log::info; use regex::Regex; use tokio::fs::{create_dir, File}; @@ -10,10 +11,16 @@ use crate::{logging::created, util::package_info}; pub async fn migrate(version: Option) { let info = package_info().await.expect("couldn't get package info"); + info!("Installing migrations from rwf"); + Migrations::install(rwf::migrations_path()) + .await + .expect("install rwf migrations failed"); + if info.rwf_auth { - rwf_auth::migrate() + info!("Installing migrations from rwf-auth"); + Migrations::install(rwf_auth::migrations_path()) .await - .expect("rwf-auth migrations failed to apply"); + .expect("install rwf-auth migrations failed"); } let migrations = Migrations::sync(None) diff --git a/rwf/Cargo.toml b/rwf/Cargo.toml index 2fd4ea9c..86235a0e 100644 --- a/rwf/Cargo.toml +++ b/rwf/Cargo.toml @@ -10,6 +10,7 @@ homepage = "https://levkk.github.io/rwf/" repository = "https://github.com/levkk/rwf" keywords = ["mvc", "web", "framework", "http", "orm"] authors = ["Lev Kokotov "] +include = ["src/", "migrations/"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/rwf/migrations/1733778837089567000_rwf_init.down.sql b/rwf/migrations/1733778837089567000_rwf_init.down.sql new file mode 100644 index 00000000..0fa189a2 --- /dev/null +++ b/rwf/migrations/1733778837089567000_rwf_init.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS rwf_requests; + +DROP TABLE IF EXISTS rwf_jobs; diff --git a/rwf/migrations/1733778837089567000_rwf_init.up.sql b/rwf/migrations/1733778837089567000_rwf_init.up.sql new file mode 100644 index 00000000..583b0b6d --- /dev/null +++ b/rwf/migrations/1733778837089567000_rwf_init.up.sql @@ -0,0 +1,45 @@ + +CREATE TABLE IF NOT EXISTS rwf_jobs ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR NOT NULL, + args JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + start_after TIMESTAMPTZ NOT NULL DEFAULT NOW(), + started_at TIMESTAMPTZ, + attempts INT NOT NULL DEFAULT 0, + retries BIGINT NOT NULL DEFAULT 25, + completed_at TIMESTAMPTZ, + error VARCHAR +); + +-- Pending jobs +CREATE INDEX IF NOT EXISTS rwf_jobs_pending_idx ON rwf_jobs USING btree(start_after, created_at) WHERE + completed_at IS NULL + AND started_at IS NULL + AND attempts < retries; + +-- Running jobs +CREATE INDEX IF NOT EXISTS rwf_jobs_runnin_idx ON rwf_jobs USING btree(start_after, created_at) WHERE + completed_at IS NULL + AND started_at IS NOT NULL + AND attempts < retries; + +CREATE INDEX IF NOT EXISTS rwf_jobs_name_completed_at_idx ON rwf_jobs USING btree(name, completed_at); + +CREATE TABLE IF NOT EXISTS rwf_requests ( + id BIGSERIAL PRIMARY KEY, + path VARCHAR NOT NULL, + method VARCHAR NOT NULL DEFAULT 'GET', + query JSONB NOT NULL DEFAULT '{}'::jsonb, + code INTEGER NOT NULL DEFAULT 200, + client_ip INET, + client_id UUID NOT NULL DEFAULT gen_random_uuid(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + duration REAL NOT NULL +); + +CREATE INDEX IF NOT EXISTS rwf_requests_path_created_at ON rwf_requests USING btree(created_at, path, client_id); + +CREATE INDEX IF NOT EXISTS rwf_requests_errors ON rwf_requests USING btree(created_at, code, client_id) WHERE code >= 400; + +CREATE INDEX IF NOT EXISTS rwf_requests_too_slow ON rwf_requests USING btree(created_at, duration, client_id) WHERE duration >= 1000.0; -- the unit is milliseconds diff --git a/rwf/src/lib.rs b/rwf/src/lib.rs index 8a42063b..e4a44eae 100644 --- a/rwf/src/lib.rs +++ b/rwf/src/lib.rs @@ -108,7 +108,7 @@ pub use tokio; /// Asynchronous PostgreSQL driver. pub use tokio_postgres; -use std::net::SocketAddr; +use std::{net::SocketAddr, path::PathBuf}; /// Convert text to snake_case. pub fn snake_case(string: &str) -> String { @@ -176,3 +176,8 @@ pub fn peer_addr(addr: &str) -> Option { None } + +/// Migrations path for rwf. +pub fn migrations_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("migrations") +} diff --git a/rwf/src/model/migrations/bootstrap.sql b/rwf/src/model/migrations/bootstrap.sql index ef6b6bf8..0566b74c 100644 --- a/rwf/src/model/migrations/bootstrap.sql +++ b/rwf/src/model/migrations/bootstrap.sql @@ -1,53 +1,9 @@ -SET LOCAL client_min_messages TO WARNING; +SET + LOCAL client_min_messages TO WARNING; CREATE TABLE IF NOT EXISTS rwf_migrations ( - id BIGSERIAL PRIMARY KEY, - version BIGINT NOT NULL, - name VARCHAR UNIQUE NOT NULL, - applied_at TIMESTAMPTZ -); - -CREATE TABLE IF NOT EXISTS rwf_jobs ( id BIGSERIAL PRIMARY KEY, - name VARCHAR NOT NULL, - args JSONB NOT NULL DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - start_after TIMESTAMPTZ NOT NULL DEFAULT NOW(), - started_at TIMESTAMPTZ, - attempts INT NOT NULL DEFAULT 0, - retries BIGINT NOT NULL DEFAULT 25, - completed_at TIMESTAMPTZ, - error VARCHAR + version BIGINT NOT NULL, + name VARCHAR UNIQUE NOT NULL, + applied_at TIMESTAMPTZ ); - --- Pending jobs -CREATE INDEX IF NOT EXISTS rwf_jobs_pending_idx ON rwf_jobs USING btree(start_after, created_at) WHERE - completed_at IS NULL - AND started_at IS NULL - AND attempts < retries; - --- Running jobs -CREATE INDEX IF NOT EXISTS rwf_jobs_runnin_idx ON rwf_jobs USING btree(start_after, created_at) WHERE - completed_at IS NULL - AND started_at IS NOT NULL - AND attempts < retries; - -CREATE INDEX IF NOT EXISTS rwf_jobs_name_completed_at_idx ON rwf_jobs USING btree(name, completed_at); - -CREATE TABLE IF NOT EXISTS rwf_requests ( - id BIGSERIAL PRIMARY KEY, - path VARCHAR NOT NULL, - method VARCHAR NOT NULL DEFAULT 'GET', - query JSONB NOT NULL DEFAULT '{}'::jsonb, - code INTEGER NOT NULL DEFAULT 200, - client_ip INET, - client_id UUID NOT NULL DEFAULT gen_random_uuid(), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - duration REAL NOT NULL -); - -CREATE INDEX IF NOT EXISTS rwf_requests_path_created_at ON rwf_requests USING btree(created_at, path, client_id); - -CREATE INDEX IF NOT EXISTS rwf_requests_errors ON rwf_requests USING btree(created_at, code, client_id) WHERE code >= 400; - -CREATE INDEX IF NOT EXISTS rwf_requests_too_slow ON rwf_requests USING btree(created_at, duration, client_id) WHERE duration >= 1000.0; -- the unit is milliseconds diff --git a/rwf/src/model/migrations/mod.rs b/rwf/src/model/migrations/mod.rs index fbbd503d..733aa4f8 100644 --- a/rwf/src/model/migrations/mod.rs +++ b/rwf/src/model/migrations/mod.rs @@ -13,7 +13,7 @@ use std::path::{Path, PathBuf}; use once_cell::sync::Lazy; use regex::Regex; use time::OffsetDateTime; -use tokio::fs::{read_dir, read_to_string}; +use tokio::fs::{copy, read_dir, read_to_string}; use tracing::{error, info}; /// Migrations found in the `"migrations"` folder. Some of them @@ -100,7 +100,8 @@ impl MigrationFile { } impl Migrations { - fn root_path(path: Option) -> Result { + /// Get the migrations folder. + pub fn root_path(path: Option) -> Result { let path = PathBuf::from( if let Some(path) = path { path @@ -120,6 +121,19 @@ impl Migrations { } } + /// Install migrations from specified path. + pub async fn install(from_path: PathBuf) -> Result<(), Error> { + let migrations_path = Self::root_path(None)?; + let mut entries = read_dir(from_path).await?; + while let Some(entry) = entries.next_entry().await? { + let src = entry.path(); + let dest = migrations_path.join(entry.path().file_name().unwrap()); + copy(src, dest).await.expect("copy"); + } + + Ok(()) + } + async fn load(root_path: Option) -> Result { let mut conn = get_connection().await?; let migrations = Migration::all().fetch_all(&mut conn).await?; From 4d90ddc0053d349ca74f08808a884e59596596b3 Mon Sep 17 00:00:00 2001 From: Lev Kokotov Date: Tue, 10 Dec 2024 15:42:02 -0800 Subject: [PATCH 7/8] clean up --- examples/users/src/main.rs | 17 ++++++--------- examples/users/templates/login.html | 2 +- examples/users/templates/signup.html | 2 +- rwf-auth/src/controllers/logout.rs | 31 ++++++++++++++++++++++++++++ rwf-auth/src/controllers/mod.rs | 6 +++--- rwf-auth/src/models/mod.rs | 3 ++- rwf-macros/src/lib.rs | 2 +- rwf-macros/src/user.rs | 2 +- 8 files changed, 46 insertions(+), 19 deletions(-) create mode 100644 rwf-auth/src/controllers/logout.rs diff --git a/examples/users/src/main.rs b/examples/users/src/main.rs index 1ecd158b..2a70cef1 100644 --- a/examples/users/src/main.rs +++ b/examples/users/src/main.rs @@ -1,5 +1,5 @@ -use rwf::controller::{LoginController, LogoutController, SignupController}; use rwf::{http::Server, prelude::*}; +use rwf_auth::controllers::{LogoutController, PasswordController}; mod controllers; mod models; @@ -7,18 +7,13 @@ mod models; #[tokio::main] async fn main() { Logger::init(); - rwf_auth::migrate().await.expect("rwf-auth migrations"); - - let signup: SignupController = - SignupController::new("templates/signup.html").redirect("/profile"); - - let login: LoginController = - LoginController::new("templates/login.html").redirect("/profile"); Server::new(vec![ - route!("/signup" => { signup }), - route!("/login" => { login }), - route!("/logout" => { LogoutController::default().redirect("/signup") }), + route!("/auth" => { + PasswordController::template("templates/login.html") + .redirect("/profile") + }), + route!("/logout" => { LogoutController::redirect("/") }), route!("/profile" => controllers::profile), ]) .launch() diff --git a/examples/users/templates/login.html b/examples/users/templates/login.html index 63503d1e..c9989586 100644 --- a/examples/users/templates/login.html +++ b/examples/users/templates/login.html @@ -6,7 +6,7 @@

Login

-
+ <%= csrf_token() %> <% if error_user_does_not_exist %> diff --git a/examples/users/templates/signup.html b/examples/users/templates/signup.html index 0b372475..a9dbd0f1 100644 --- a/examples/users/templates/signup.html +++ b/examples/users/templates/signup.html @@ -6,7 +6,7 @@

Create account

- + <% if error_user_exists %>
Account with this email already exists. diff --git a/rwf-auth/src/controllers/logout.rs b/rwf-auth/src/controllers/logout.rs new file mode 100644 index 00000000..c427f77a --- /dev/null +++ b/rwf-auth/src/controllers/logout.rs @@ -0,0 +1,31 @@ +//! Logout controller. +use rwf::prelude::*; + +/// Log the user out. +pub struct LogoutController { + redirect: String, +} + +impl LogoutController { + /// Redirect user to this URL after logging out. + pub fn redirect(redirect: impl ToString) -> Self { + Self { + redirect: redirect.to_string(), + } + } +} + +impl Default for LogoutController { + fn default() -> Self { + Self { + redirect: "/".to_string(), + } + } +} + +#[async_trait] +impl Controller for LogoutController { + async fn handle(&self, request: &Request) -> Result { + Ok(request.logout().redirect(self.redirect.clone())) + } +} diff --git a/rwf-auth/src/controllers/mod.rs b/rwf-auth/src/controllers/mod.rs index ec0c9899..ae48f8b1 100644 --- a/rwf-auth/src/controllers/mod.rs +++ b/rwf-auth/src/controllers/mod.rs @@ -1,5 +1,5 @@ -// This file is automatically generated by rwf-cli. -// Manual modifications to this file will not be preserved. +pub mod logout; pub mod password; -pub use password::{Password, PasswordController}; +pub use logout::LogoutController; +pub use password::PasswordController; diff --git a/rwf-auth/src/models/mod.rs b/rwf-auth/src/models/mod.rs index 4e311908..0025381f 100644 --- a/rwf-auth/src/models/mod.rs +++ b/rwf-auth/src/models/mod.rs @@ -1,7 +1,8 @@ use rwf::prelude::*; -#[derive(Clone, macros::Model, macros::UserModel, Debug)] +#[derive(Clone, macros::Model, Debug, macros::UserModel)] #[table_name("rwf_auth_users")] +#[user_model(identifier, password)] pub struct User { id: Option, identifier: String, diff --git a/rwf-macros/src/lib.rs b/rwf-macros/src/lib.rs index 9afed369..3a14efb0 100644 --- a/rwf-macros/src/lib.rs +++ b/rwf-macros/src/lib.rs @@ -724,7 +724,7 @@ pub fn controller(_args: TokenStream, input: TokenStream) -> TokenStream { .into() } -#[proc_macro_derive(UserModel, attributes(user_model))] +#[proc_macro_derive(UserModel, attributes(user_model, rwf))] pub fn derive_user_model(input: TokenStream) -> TokenStream { user::impl_derive_user_model(input) } diff --git a/rwf-macros/src/user.rs b/rwf-macros/src/user.rs index 4058ad9c..cfd90ae4 100644 --- a/rwf-macros/src/user.rs +++ b/rwf-macros/src/user.rs @@ -22,7 +22,7 @@ pub(crate) fn impl_derive_user_model(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); let ident = &input.ident; - if let Some(attr) = input.attrs.first() { + for attr in input.attrs { match attr.meta { Meta::List(ref attrs) => { if let Ok(attrs) = syn::parse2::(attrs.tokens.clone()) { From 88995354b6c56bbc1e42bb5d6e5f5737484ace6f Mon Sep 17 00:00:00 2001 From: Lev Kokotov Date: Tue, 10 Dec 2024 16:03:51 -0800 Subject: [PATCH 8/8] move more to rwf-auth --- rwf/src/controller/mod.rs | 2 - rwf/src/controller/user.rs | 178 ------------------------------------- 2 files changed, 180 deletions(-) delete mode 100644 rwf/src/controller/user.rs diff --git a/rwf/src/controller/mod.rs b/rwf/src/controller/mod.rs index b8ad3870..50006213 100644 --- a/rwf/src/controller/mod.rs +++ b/rwf/src/controller/mod.rs @@ -33,7 +33,6 @@ pub mod middleware; pub mod ser; pub mod static_files; pub mod turbo_stream; -pub mod user; #[cfg(feature = "wsgi")] pub mod wsgi; @@ -51,7 +50,6 @@ pub use error::Error; pub use middleware::{Middleware, MiddlewareHandler, MiddlewareSet, Outcome, RateLimiter}; pub use static_files::{CacheControl, StaticFiles}; pub use turbo_stream::TurboStream; -pub use user::{LoginController, LogoutController, SignupController}; use super::http::{ websocket::{self, DataFrame}, diff --git a/rwf/src/controller/user.rs b/rwf/src/controller/user.rs deleted file mode 100644 index 235867de..00000000 --- a/rwf/src/controller/user.rs +++ /dev/null @@ -1,178 +0,0 @@ -use std::marker::PhantomData; - -use super::{Controller, Error, PageController}; -use crate::view::{Context, Template}; -use crate::{ - http::{Request, Response}, - model::{user::Error as UserError, UserModel}, -}; -use async_trait::async_trait; - -/// Controller to log a user in. -pub struct LoginController { - redirect: Option, - template: &'static str, - signup: bool, - _marker: PhantomData, -} - -/// Controller to create user accounts. -pub struct SignupController(LoginController); - -impl SignupController { - /// Create new signup controller with the given template. - pub fn new(template: &'static str) -> Self { - Self(LoginController::::new(template).signup()) - } - - /// Redirect to this URL if account creation was successful. - pub fn redirect(mut self, redirect: impl ToString) -> Self { - self.0 = self.0.redirect(redirect); - self - } -} - -#[async_trait] -impl Controller for SignupController { - async fn handle(&self, request: &Request) -> Result { - PageController::handle(&self.0, request).await - } -} - -impl LoginController { - /// Create new login controller with the provided template. - pub fn new(template: &'static str) -> Self { - Self { - redirect: None, - template, - signup: false, - _marker: PhantomData, - } - } - - /// Redirect on successful account creation to this URL. - pub fn redirect(mut self, redirect: impl ToString) -> Self { - self.redirect = Some(redirect.to_string()); - self - } - - fn signup(mut self) -> Self { - self.signup = true; - self - } - - fn error(&self, request: &Request, error: &str) -> Result { - let template = Template::load(self.template)?; - let mut ctx = Context::new(); - - ctx.set(error, true)?; - ctx.set("request", request.clone())?; - - Ok(Response::new().html(template.render(&ctx)?).code(400)) - } -} - -#[async_trait] -impl PageController for LoginController { - async fn get(&self, request: &Request) -> Result { - let mut ctx = Context::new(); - - ctx.set("request", request.clone())?; - - let template = Template::load(self.template)?; - - Ok(Response::new().html(template.render(&ctx)?)) - } - - async fn post(&self, request: &Request) -> Result { - let form = request.form_data()?; - - let identifier: String = match form.get_required("identifier") { - Ok(field) => field, - Err(_) => return self.error(request, "error_identifier"), - }; - let identifier = identifier.trim().to_string(); - - let password: String = match form.get_required("password") { - Ok(field) => field, - Err(_) => return self.error(request, "error_password"), - }; - - if self.signup { - match T::create_user(identifier, password).await { - Ok(user) => { - let id = user.id().integer()?; - let response = request.login(id); - - if let Some(ref redirect) = self.redirect { - Ok(response.redirect(redirect)) - } else { - Ok(response) - } - } - Err(err) => match err { - UserError::UserExists => return self.error(request, "error_user_exists"), - err => return Err(err.into()), - }, - } - } else { - match T::login_user(identifier, password).await { - Ok(user) => { - let id = user.id().integer()?; - let response = request.login(id); - - if let Some(ref redirect) = self.redirect { - Ok(response.redirect(redirect)) - } else { - Ok(response) - } - } - Err(err) => match err { - UserError::UserDoesNotExist => { - return self.error(request, "error_user_does_not_exist") - } - UserError::WrongPassword => return self.error(request, "error_password"), - err => return Err(err.into()), - }, - } - } - } -} - -#[async_trait] -impl Controller for LoginController { - async fn handle(&self, request: &Request) -> Result { - PageController::handle(self, request).await - } -} - -/// Controller to log the user out. -#[derive(Default)] -pub struct LogoutController { - redirect: Option, -} - -impl LogoutController { - /// Create new logout controller. - pub fn new() -> Self { - Self { redirect: None } - } - - /// Redirect to this URL after logging the user out. - pub fn redirect(mut self, redirect: impl ToString) -> Self { - self.redirect = Some(redirect.to_string()); - self - } -} - -#[async_trait] -impl Controller for LogoutController { - async fn handle(&self, request: &Request) -> Result { - let response = request.logout(); - if let Some(ref redirect) = self.redirect { - Ok(response.redirect(redirect)) - } else { - Ok(response) - } - } -}