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!
+
+
+
+Now, head on over to the login page and get watchin'!
+
+{% endblock %}