diff --git a/Cargo.lock b/Cargo.lock index 40a404dd..84c06197 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1973,15 +1973,23 @@ dependencies = [ "uuid", ] +[[package]] +name = "rwf-auth" +version = "0.1.0" +dependencies = [ + "rwf", +] + [[package]] name = "rwf-cli" -version = "0.1.14" +version = "0.2.2" dependencies = [ "clap", "flate2", "log", "regex", "rwf", + "rwf-auth", "serde_json", "tar", "time", @@ -2684,6 +2692,7 @@ version = "0.1.0" dependencies = [ "argon2", "rwf", + "rwf-auth", "time", ] 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/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/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/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 332e67be..2a70cef1 100644 --- a/examples/users/src/main.rs +++ b/examples/users/src/main.rs @@ -1,4 +1,5 @@ use rwf::{http::Server, prelude::*}; +use rwf_auth::controllers::{LogoutController, PasswordController}; mod controllers; mod models; @@ -8,8 +9,11 @@ async fn main() { Logger::init(); Server::new(vec![ - route!("/signup" => controllers::Signup), - route!("/login" => controllers::login), + route!("/auth" => { + PasswordController::template("templates/login.html") + .redirect("/profile") + }), + route!("/logout" => { LogoutController::redirect("/") }), route!("/profile" => controllers::profile), ]) .launch() diff --git a/examples/users/src/models.rs b/examples/users/src/models.rs index 166096d0..f86edd57 100644 --- a/examples/users/src/models.rs +++ b/examples/users/src/models.rs @@ -1,74 +1,10 @@ -// use rwf::model::Error; -use rwf::crypto::{hash, hash_validate}; use rwf::prelude::*; -use tokio::task::spawn_blocking; -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, 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..c9989586 --- /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 fe25897e..a9dbd0f1 100644 --- a/examples/users/templates/signup.html +++ b/examples/users/templates/signup.html @@ -1,22 +1,21 @@ - - - + <%% "templates/head.html" %> -
-
- <% if error %> +
+

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() %>
- +
@@ -24,9 +23,14 @@
+
+ + +
+
diff --git a/rwf-auth/Cargo.toml b/rwf-auth/Cargo.toml new file mode 100644 index 00000000..7da7f644 --- /dev/null +++ b/rwf-auth/Cargo.toml @@ -0,0 +1,8 @@ +[package] +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/.gitkeep b/rwf-auth/migrations/.gitkeep new file mode 100644 index 00000000..e69de29b 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/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 new file mode 100644 index 00000000..ae48f8b1 --- /dev/null +++ b/rwf-auth/src/controllers/mod.rs @@ -0,0 +1,5 @@ +pub mod logout; +pub mod password; + +pub use logout::LogoutController; +pub use password::PasswordController; diff --git a/rwf-auth/src/controllers/password.rs b/rwf-auth/src/controllers/password.rs new file mode 100644 index 00000000..09dfdc16 --- /dev/null +++ b/rwf-auth/src/controllers/password.rs @@ -0,0 +1,141 @@ +//! 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::{ + model::{user::Error as UserError, UserModel}, + prelude::*, +}; + +use crate::models::User; + +/// Account creation and login form. +#[derive(macros::Form)] +pub struct PasswordForm { + identifier: String, + password: String, + password2: Option, +} + +/// Password errors. +/// +/// These are passed to the template in the context. +#[derive(macros::Context, Default)] +pub struct Errors { + /// 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_form = true; + ctx + } + + fn wrong_password() -> Self { + let mut ctx = Self::default(); + 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, + redirect_url: String, + _marker: PhantomData, +} + +impl Password { + /// Create controller with the specified template path. + pub fn template(template_path: &str) -> Self { + Self { + template_path: template_path.to_owned(), + redirect_url: "/".into(), + _marker: PhantomData, + } + } + + /// 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 + } +} + +#[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)); + }; + + // 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) => { + 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)) + } +} + +/// 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 new file mode 100644 index 00000000..ec27330e --- /dev/null +++ b/rwf-auth/src/lib.rs @@ -0,0 +1,16 @@ +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 +} + +pub fn migrations_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("migrations") +} diff --git a/rwf-auth/src/models/mod.rs b/rwf-auth/src/models/mod.rs new file mode 100644 index 00000000..0025381f --- /dev/null +++ b/rwf-auth/src/models/mod.rs @@ -0,0 +1,11 @@ +use rwf::prelude::*; + +#[derive(Clone, macros::Model, Debug, macros::UserModel)] +#[table_name("rwf_auth_users")] +#[user_model(identifier, password)] +pub struct User { + id: Option, + identifier: String, + password: String, + created_at: OffsetDateTime, +} 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-cli/Cargo.toml b/rwf-cli/Cargo.toml index 862b9d85..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" @@ -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..1d6e927c 100644 --- a/rwf-cli/src/migrate.rs +++ b/rwf-cli/src/migrate.rs @@ -2,13 +2,30 @@ 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}; -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"); + + info!("Installing migrations from rwf"); + Migrations::install(rwf::migrations_path()) + .await + .expect("install rwf migrations failed"); + + if info.rwf_auth { + info!("Installing migrations from rwf-auth"); + Migrations::install(rwf_auth::migrations_path()) + .await + .expect("install rwf-auth migrations failed"); + } + + let migrations = Migrations::sync(None) + .await + .expect("failed to sync migrations"); migrations .apply(Direction::Up, version) @@ -17,7 +34,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 { /// ``` #[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, rwf))] +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/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; 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..cfd90ae4 --- /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; + + for attr in input.attrs { + match attr.meta { + Meta::List(ref attrs) => { + 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(); + } + } + + _ => (), + } + } + + quote! {}.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/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/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/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/error.rs b/rwf/src/model/error.rs index b2f692dd..028939da 100644 --- a/rwf/src/model/error.rs +++ b/rwf/src/model/error.rs @@ -47,6 +47,12 @@ 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, + + #[error("value is not a string")] + NotAString, } impl Error { 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 049e8398..733aa4f8 100644 --- a/rwf/src/model/migrations/mod.rs +++ b/rwf/src/model/migrations/mod.rs @@ -13,13 +13,14 @@ 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 /// may not be applied yet. pub struct Migrations { migrations: Vec, + root_path: Option, } static RE: Lazy = @@ -99,8 +100,16 @@ impl MigrationFile { } impl Migrations { - fn root_path() -> Result { - let path = PathBuf::from(current_dir()?.join(Path::new("migrations"))); + /// Get the migrations folder. + pub 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 +121,35 @@ impl Migrations { } } - async fn load() -> Result { + /// 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?; - 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 +224,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 +268,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 +319,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 +329,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)] diff --git a/rwf/src/model/mod.rs b/rwf/src/model/mod.rs index b6d204e1..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; @@ -28,6 +31,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 +52,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 +598,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)), @@ -1454,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/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..35609d7e --- /dev/null +++ b/rwf/src/model/user.rs @@ -0,0 +1,100 @@ +use super::{Model, Pool, 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 = 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 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(password.as_bytes(), &column)) + .await + .unwrap() + .unwrap(); + + if valid { + Ok(user) + } else { + Err(Error::WrongPassword) + } + } else { + Err(Error::UserDoesNotExist) + } + } +} diff --git a/rwf/src/model/value.rs b/rwf/src/model/value.rs index 8d55c679..518465c0 100644 --- a/rwf/src/model/value.rs +++ b/rwf/src/model/value.rs @@ -114,6 +114,32 @@ 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 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