diff --git a/.env b/.env new file mode 100644 index 0000000..04f6365 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +DATABASE_URL=sqlite://${HOME}/.witch-watch.db diff --git a/Cargo.lock b/Cargo.lock index 0364a0a..09fecbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2447,6 +2447,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "urlencoding" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8db7427f936968176eaa7cdf81b7f98b980b18495ec28f1b5791ac3bfe3eea9" + [[package]] name = "uuid" version = "0.8.2" @@ -2793,6 +2799,7 @@ dependencies = [ "tracing", "tracing-subscriber", "unicode-segmentation", + "urlencoding", "uuid 1.3.1", ] diff --git a/Cargo.toml b/Cargo.toml index cf9377a..49fb315 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,3 +23,4 @@ justerror = "1.1.0" password-hash = { version = "0.5.0", features = ["std", "getrandom"] } axum-login = { version = "0.5.0", features = ["sqlite", "sqlx"] } unicode-segmentation = "1.10.1" +urlencoding = "2.1.2" diff --git a/src/lib.rs b/src/lib.rs index e51e517..f97526d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,4 +3,5 @@ extern crate justerror; pub mod db; pub mod handlers; +pub(crate) mod templates; pub mod users; diff --git a/src/main.rs b/src/main.rs index bb8f66d..51e79c6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,10 @@ use std::net::SocketAddr; use axum::{routing::get, Router}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -use witch_watch::{db, handlers}; +use witch_watch::{ + db, + users::{get_create_user, handle_signup_success, post_create_user}, +}; #[tokio::main] async fn main() { @@ -16,16 +19,12 @@ async fn main() { let pool = db::get_pool().await; - let _ = witch_watch::users::create_user("joe", &None, &None, &[], &pool) - .await - .unwrap(); - // build our application with some routes - use handlers::*; let app = Router::new() + .route("/signup", get(get_create_user).post(post_create_user)) .route( - "/", - get(using_connection_pool_extractor).post(using_connection_extractor), + "/signup_success/:id", + get(handle_signup_success).post(handle_signup_success), ) .with_state(pool); diff --git a/src/template.rs b/src/template.rs deleted file mode 100644 index 2b95388..0000000 --- a/src/template.rs +++ /dev/null @@ -1,35 +0,0 @@ -use askama::Template; -use axum::{ - extract, - http::StatusCode, - response::{Html, IntoResponse, Response}, -}; - -pub(crate) async fn greet(extract::Path(name): extract::Path) -> impl IntoResponse { - let template = HelloTemplate { name }; - HtmlTemplate(template) -} - -#[derive(Template)] -#[template(path = "hello.html")] -struct HelloTemplate { - name: String, -} - -struct HtmlTemplate(T); - -impl IntoResponse for HtmlTemplate -where - T: Template, -{ - fn into_response(self) -> Response { - match self.0.render() { - Ok(html) => Html(html).into_response(), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to render template. Error: {}", err), - ) - .into_response(), - } - } -} diff --git a/src/templates.rs b/src/templates.rs new file mode 100644 index 0000000..afbc50d --- /dev/null +++ b/src/templates.rs @@ -0,0 +1,12 @@ +use askama::Template; +use serde::Deserialize; + +#[derive(Debug, Default, Template, Deserialize)] +#[template(path = "signup.html")] +pub struct CreateUser { + pub username: String, + pub displayname: Option, + pub email: Option, + pub password: String, + pub pw_verify: String, +} diff --git a/src/users.rs b/src/users.rs index 47d65a1..a140739 100644 --- a/src/users.rs +++ b/src/users.rs @@ -1,57 +1,177 @@ +use std::fmt::Display; + use argon2::{ password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2, }; -use sqlx::{error::DatabaseError, Sqlite, SqlitePool}; -use tracing::log::log; +use askama::Template; +use axum::{ + extract::{Form, Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use sqlx::{sqlite::SqliteRow, Row, SqlitePool}; use unicode_segmentation::UnicodeSegmentation; use uuid::Uuid; +use crate::templates::CreateUser; + const CREATE_QUERY: &str = "insert into witches (id, username, displayname, email, pwhash) values ($1, $2, $3, $4, $5)"; +const ID_QUERY: &str = "select * from witches where id = $1"; + +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct User { id: Uuid, username: String, displayname: Option, email: Option, + last_seen: Option, } -#[derive(Debug, Clone, sqlx::FromRow, sqlx::Encode)] -pub(crate) struct DbUser { - id: Uuid, - username: String, - displayname: Option, - email: Option, - last_seen: Option, - pwhash: String, -} - -impl From for User { - fn from(dbu: DbUser) -> Self { - User { - id: dbu.id, - username: dbu.username, - displayname: dbu.displayname, - email: dbu.email, - } +impl Display for User { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let uname = &self.username; + let dname = if let Some(ref n) = self.displayname { + n + } else { + "" + }; + let email = if let Some(ref e) = self.email { e } else { "" }; + write!(f, "Username: {uname}\nDisplayname: {dname}\nEmail: {email}") } } -pub async fn create_user( - username: &str, - displayname: &Option, - email: &Option, - password: &[u8], - pool: &SqlitePool, -) -> Result { +#[derive(Debug, Clone, Template)] +#[template(path = "signup_success.html")] +pub struct CreateUserSuccess(User); + +impl sqlx::FromRow<'_, SqliteRow> for User { + fn from_row(row: &SqliteRow) -> Result { + let bytes: Vec = row.get("id"); + let bytes = bytes.as_slice(); + let bytes: [u8; 16] = bytes.try_into().unwrap(); + let id = Uuid::from_bytes_le(bytes); + let username: String = row.get("username"); + let displayname: Option = row.get("displayname"); + let last_seen: Option = row.get("last_seen"); + let email: Option = row.get("email"); + + Ok(Self { + id, + username, + displayname, + email, + last_seen, + }) + } +} + +/// Get Handler: displays the form to create a user +pub async fn get_create_user() -> CreateUser { + CreateUser::default() +} + +/// Post Handler: validates form values and calls the actual, private user +/// creation function +#[axum::debug_handler] +pub async fn post_create_user( + State(pool): State, + Form(signup): Form, +) -> Result { + let username = &signup.username; + let displayname = &signup.displayname; + let email = &signup.email; + let password = &signup.password; + let verify = &signup.pw_verify; let username = username.trim(); + let name_len = username.graphemes(true).size_hint().1.unwrap(); // we are not ascii exclusivists around here if !(1..=20).contains(&name_len) { return Err(CreateUserErrorKind::BadUsername.into()); } + if let Some(ref dn) = displayname { + if dn.len() > 50 { + return Err(CreateUserErrorKind::BadDisplayname.into()); + } + } + + if password != verify { + return Err(CreateUserErrorKind::PasswordMismatch.into()); + } + + let password = urlencoding::decode(password) + .map_err(|_| CreateUserErrorKind::BadPassword)? + .to_string(); + let password = password.as_bytes(); + + let displayname = if let Some(dn) = displayname { + let dn = urlencoding::decode(dn) + .map_err(|_| CreateUserErrorKind::BadDisplayname)? + .to_string(); + Some(dn) + } else { + None + }; + let displayname = &displayname; + + // TODO(2023-05-17): validate email + let email = if let Some(email) = email { + let email = urlencoding::decode(email) + .map_err(|_| CreateUserErrorKind::BadEmail)? + .to_string(); + Some(email) + } else { + None + }; + let email = &email; + + let user = create_user(username, displayname, email, password, &pool).await?; + tracing::debug!("created {user:?}"); + let id = user.id.simple().to_string(); + let location = format!("/signup_success/{id}"); + + let resp = axum::response::Redirect::temporary(&location).into_response(); + + Ok(resp) +} + +/// Get handler for successful signup +pub async fn handle_signup_success( + Path(id): Path, + State(pool): State, +) -> Response { + let user: User = { + let id = id; + let id = Uuid::try_parse(&id).unwrap_or_default(); + let id_bytes = id.to_bytes_le(); + sqlx::query_as(ID_QUERY) + .bind(id_bytes.as_slice()) + .fetch_one(&pool) + .await + .unwrap_or_default() + }; + + let mut resp = CreateUserSuccess(user.clone()).into_response(); + + if user.username.is_empty() { + // redirect to front page if we got here without a valid witch header + *resp.status_mut() = StatusCode::TEMPORARY_REDIRECT; + resp.headers_mut().insert("Location", "/".parse().unwrap()); + } + resp +} + +async fn create_user( + username: &str, + displayname: &Option, + email: &Option, + password: &[u8], + pool: &SqlitePool, +) -> Result { // Argon2 with default params (Argon2id v19) let argon2 = Argon2::default(); let salt = SaltString::generate(&mut OsRng); @@ -79,6 +199,7 @@ pub async fn create_user( username: username.to_string(), displayname: displayname.to_owned(), email: email.to_owned(), + last_seen: None, }; Ok(user) } @@ -89,13 +210,13 @@ pub async fn create_user( if exit == 2067u32 || exit == 1555 { Err(CreateUserErrorKind::AlreadyExists.into()) } else { - Err(CreateUserErrorKind::Unknown.into()) + Err(CreateUserErrorKind::UnknownDBError.into()) } } else { - Err(CreateUserErrorKind::Unknown.into()) + Err(CreateUserErrorKind::UnknownDBError.into()) } } - _ => Err(CreateUserErrorKind::Unknown.into()), + _ => Err(CreateUserErrorKind::UnknownDBError.into()), } } @@ -103,6 +224,17 @@ pub async fn create_user( #[non_exhaustive] pub struct CreateUserError(#[from] CreateUserErrorKind); +impl IntoResponse for CreateUserError { + fn into_response(self) -> askama_axum::Response { + match self.0 { + CreateUserErrorKind::UnknownDBError => { + (StatusCode::INTERNAL_SERVER_ERROR, format!("{self}")).into_response() + } + _ => (StatusCode::BAD_REQUEST, format!("{self}")).into_response(), + } + } +} + #[Error] #[non_exhaustive] pub enum CreateUserErrorKind { @@ -110,6 +242,9 @@ pub enum CreateUserErrorKind { #[error(desc = "Usernames must be between 1 and 20 non-whitespace characters long")] BadUsername, PasswordMismatch, + BadPassword, + BadDisplayname, + BadEmail, MissingFields, - Unknown, + UnknownDBError, } diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..ba52e06 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,18 @@ + + + + {% block title %}{{ title }} - Witch Watch{% endblock %} + {% block head %}{% endblock %} + + + +
+ {% block content %}{% endblock %} +
+ + + diff --git a/templates/signup.html b/templates/signup.html new file mode 100644 index 0000000..61b5b44 --- /dev/null +++ b/templates/signup.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} + +{% block title %}Sign Up for Witch Watch, Bish{% endblock %} + +{% block content %} + +

+

+ +
+ +
+ +
+ +
+ +
+ +
+

+ +{% endblock %} diff --git a/templates/signup_success.html b/templates/signup_success.html new file mode 100644 index 0000000..f8e9efc --- /dev/null +++ b/templates/signup_success.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} + +{% block title %}Thanks for Signing Up for Witch Watch, Bish{% endblock %} + +{% block content %} + +

You did it!

+ +

+{{ self.0 }} +

+
+ +

Now, head on over to the login page and get watchin'!

+ +{% endblock %}