Ready to test
This commit is contained in:
parent
ba027bb1cd
commit
022f0e6325
9 changed files with 418 additions and 223 deletions
105
Cargo.lock
generated
105
Cargo.lock
generated
|
@ -187,6 +187,15 @@ version = "1.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chrono"
|
||||||
|
version = "0.4.34"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5bc015644b92d5890fab7489e49d21f879d5c990186827d42ec511919404f38b"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cookie"
|
name = "cookie"
|
||||||
version = "0.18.0"
|
version = "0.18.0"
|
||||||
|
@ -607,6 +616,15 @@ version = "0.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-traits"
|
||||||
|
version = "0.2.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num_cpus"
|
name = "num_cpus"
|
||||||
version = "1.16.0"
|
version = "1.16.0"
|
||||||
|
@ -721,6 +739,7 @@ dependencies = [
|
||||||
"askama",
|
"askama",
|
||||||
"askama_axum",
|
"askama_axum",
|
||||||
"axum",
|
"axum",
|
||||||
|
"chrono",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"justerror",
|
"justerror",
|
||||||
|
@ -728,6 +747,7 @@ dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"rand",
|
"rand",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
@ -785,12 +805,58 @@ dependencies = [
|
||||||
"bitflags 1.3.2",
|
"bitflags 1.3.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ring"
|
||||||
|
version = "0.17.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"cfg-if",
|
||||||
|
"getrandom",
|
||||||
|
"libc",
|
||||||
|
"spin",
|
||||||
|
"untrusted",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-demangle"
|
name = "rustc-demangle"
|
||||||
version = "0.1.23"
|
version = "0.1.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76"
|
checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls"
|
||||||
|
version = "0.22.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e87c9956bd9807afa1f77e0f7594af32566e830e088a5576d27c5b6f30f49d41"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-webpki",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-pki-types"
|
||||||
|
version = "1.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5ede67b28608b4c60685c7d54122d4400d90f62b40caee7700e700380a390fa8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-webpki"
|
||||||
|
version = "0.102.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "faaa0a62740bedb9b2ef5afa303da42764c012f743917351dc9a237ea1663610"
|
||||||
|
dependencies = [
|
||||||
|
"ring",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"untrusted",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.14"
|
version = "1.0.14"
|
||||||
|
@ -877,6 +943,18 @@ dependencies = [
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spin"
|
||||||
|
version = "0.9.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "subtle"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "1.0.109"
|
version = "1.0.109"
|
||||||
|
@ -1201,6 +1279,12 @@ version = "1.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202"
|
checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "untrusted"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ureq"
|
name = "ureq"
|
||||||
version = "2.9.6"
|
version = "2.9.6"
|
||||||
|
@ -1210,7 +1294,13 @@ dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"log",
|
"log",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"rustls-webpki",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"url",
|
"url",
|
||||||
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -1236,6 +1326,15 @@ version = "0.11.0+wasi-snapshot-preview1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
|
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "0.26.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b3de34ae270483955a94f4b21bdaaeb83d508bb84a01435f393818edb0012009"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.48.0"
|
version = "0.48.0"
|
||||||
|
@ -1367,3 +1466,9 @@ name = "windows_x86_64_msvc"
|
||||||
version = "0.52.4"
|
version = "0.52.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8"
|
checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zeroize"
|
||||||
|
version = "1.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d"
|
||||||
|
|
|
@ -7,6 +7,7 @@ edition = "2021"
|
||||||
askama = { version = "0.12", default-features = false, features = ["with-axum", "serde"] }
|
askama = { version = "0.12", default-features = false, features = ["with-axum", "serde"] }
|
||||||
askama_axum = { version = "0.4", default-features = false }
|
askama_axum = { version = "0.4", default-features = false }
|
||||||
axum = { version = "0.7", default-features = false, features = ["tokio", "http1", "form"] }
|
axum = { version = "0.7", default-features = false, features = ["tokio", "http1", "form"] }
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["now"] }
|
||||||
dotenvy = { version = "0.15", default-features = false }
|
dotenvy = { version = "0.15", default-features = false }
|
||||||
env_logger = { version = "0.11", default-features = false, features = ["humantime"] }
|
env_logger = { version = "0.11", default-features = false, features = ["humantime"] }
|
||||||
justerror = { version = "1" }
|
justerror = { version = "1" }
|
||||||
|
@ -14,10 +15,11 @@ lazy_static = "1"
|
||||||
log = { version = "0.4", default-features = false }
|
log = { version = "0.4", default-features = false }
|
||||||
rand = { version = "0.8", default-features = false, features = ["getrandom"] }
|
rand = { version = "0.8", default-features = false, features = ["getrandom"] }
|
||||||
serde = { version = "1", default-features = false, features = ["derive"] }
|
serde = { version = "1", default-features = false, features = ["derive"] }
|
||||||
|
serde_json = { version = "1", default-features = false }
|
||||||
thiserror = { version = "1" }
|
thiserror = { version = "1" }
|
||||||
time = { version = "0.3", default-features = false }
|
time = { version = "0.3", default-features = false }
|
||||||
tokio = { version = "1", default-features = false, features = ["rt-multi-thread"] }
|
tokio = { version = "1", default-features = false, features = ["rt-multi-thread"] }
|
||||||
tower-http = { version = "0.5", default-features = false, features = ["fs"] }
|
tower-http = { version = "0.5", default-features = false, features = ["fs"] }
|
||||||
tower-sessions = { version = "0.10", default-features = false, features = ["axum-core", "memory-store"] }
|
tower-sessions = { version = "0.10", default-features = false, features = ["axum-core", "memory-store"] }
|
||||||
unicode-segmentation = { version = "1", default-features = false }
|
unicode-segmentation = { version = "1", default-features = false }
|
||||||
ureq = { version = "2", default-features = false }
|
ureq = { version = "2", default-features = false, features = ["json", "tls"] }
|
||||||
|
|
209
src/handlers.rs
209
src/handlers.rs
|
@ -1,209 +0,0 @@
|
||||||
use std::{error::Error, fmt::Debug, ops::RangeInclusive};
|
|
||||||
|
|
||||||
use axum::{
|
|
||||||
extract::{Form, Path},
|
|
||||||
http::StatusCode,
|
|
||||||
response::{IntoResponse, Redirect, Response},
|
|
||||||
};
|
|
||||||
use rand::random;
|
|
||||||
use serde::Deserialize;
|
|
||||||
use tower_sessions::Session;
|
|
||||||
use unicode_segmentation::UnicodeSegmentation;
|
|
||||||
|
|
||||||
use crate::{templates::*, User};
|
|
||||||
|
|
||||||
const PASSWORD_LEN: RangeInclusive<usize> = 4..=100;
|
|
||||||
const USERNAME_LEN: RangeInclusive<usize> = 1..=50;
|
|
||||||
const DISPLAYNAME_LEN: RangeInclusive<usize> = 0..=100;
|
|
||||||
const EMAIL_LEN: RangeInclusive<usize> = 4..=50;
|
|
||||||
|
|
||||||
lazy_static! {
|
|
||||||
static ref SIGNUP_KEY: String = format!("meow-{}", random::<u128>());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Error(desc = "Could not create user.")]
|
|
||||||
#[non_exhaustive]
|
|
||||||
pub struct CreateUserError(#[from] pub CreateUserErrorKind);
|
|
||||||
|
|
||||||
impl IntoResponse for CreateUserError {
|
|
||||||
fn into_response(self) -> Response {
|
|
||||||
let mut resp = SignupErrorPage(format!("{self}")).into_response();
|
|
||||||
*resp.status_mut() = StatusCode::FORBIDDEN;
|
|
||||||
resp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Error]
|
|
||||||
#[non_exhaustive]
|
|
||||||
pub enum CreateUserErrorKind {
|
|
||||||
#[error(desc = "That username already exists")]
|
|
||||||
AlreadyExists,
|
|
||||||
#[error(desc = "Usernames must be between 1 and 50 characters long")]
|
|
||||||
BadUsername,
|
|
||||||
#[error(desc = "Your passwords didn't match")]
|
|
||||||
PasswordMismatch,
|
|
||||||
#[error(desc = "Password must have at least 4 and at most 100 characters")]
|
|
||||||
BadPassword,
|
|
||||||
#[error(desc = "Display name must be less than 100 characters long")]
|
|
||||||
BadDisplayname,
|
|
||||||
#[error(desc = "Your email is too short, it simply can't be real")]
|
|
||||||
BadEmail,
|
|
||||||
#[error(desc = "We could not verify your payment")]
|
|
||||||
BadPayment,
|
|
||||||
#[error(desc = "We couldn't retrieve your info from this browser session")]
|
|
||||||
NoFormFound,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize, PartialEq, Eq)]
|
|
||||||
pub struct SignupForm {
|
|
||||||
pub username: String,
|
|
||||||
#[serde(default, deserialize_with = "empty_string_as_none")]
|
|
||||||
pub displayname: Option<String>,
|
|
||||||
pub email: String,
|
|
||||||
pub password: String,
|
|
||||||
pub pw_verify: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Displays the signup form.
|
|
||||||
pub async fn get_signup() -> impl IntoResponse {
|
|
||||||
SignupPage {
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Receives the form with the user signup fields filled out.
|
|
||||||
pub async fn post_signup(
|
|
||||||
session: Session,
|
|
||||||
Form(form): Form<SignupForm>,
|
|
||||||
) -> Result<impl IntoResponse, CreateUserError> {
|
|
||||||
let user = validate_signup(&form).await?;
|
|
||||||
session.insert(&SIGNUP_KEY, user).await.unwrap();
|
|
||||||
|
|
||||||
Ok(Redirect::to(
|
|
||||||
"https://buy.stripe.com/test_eVa6rrb7ygjNbwk000",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
/// Handles the case when there was an error in the form
|
|
||||||
pub async fn get_edit_signup(
|
|
||||||
session: Session,
|
|
||||||
receipt: Option<Path<String>>,
|
|
||||||
) -> Result<impl IntoResponse, CreateUserError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
/// Redirected from Stripe with the receipt of payment.
|
|
||||||
pub async fn payment_success(session: Session, receipt: Option<Path<String>>) -> impl IntoResponse {
|
|
||||||
let user: User = session.get(&SIGNUP_KEY).await.unwrap().unwrap_or_default();
|
|
||||||
|
|
||||||
if receipt.is_none() {
|
|
||||||
log::info!("Got {:?} from the session, but no receipt.", &user);
|
|
||||||
return CreateUserError(CreateUserErrorKind::BadPayment).into_response();
|
|
||||||
}
|
|
||||||
let Path(receipt) = receipt.unwrap();
|
|
||||||
|
|
||||||
if user == User::default() {
|
|
||||||
log::warn!("Could not find user in session; got receipt {}", receipt);
|
|
||||||
return CreateUserError(CreateUserErrorKind::NoFormFound).into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: call the forgejo admin api to create the user
|
|
||||||
|
|
||||||
session.delete().await.unwrap_or_else(|e| {
|
|
||||||
log::error!("Got error deleting {} from session, got {}", &user, e);
|
|
||||||
});
|
|
||||||
|
|
||||||
log::info!("Added {:?}", &user);
|
|
||||||
SignupSuccessPage(user).into_response()
|
|
||||||
}
|
|
||||||
|
|
||||||
//-************************************************************************
|
|
||||||
// helpers
|
|
||||||
//-************************************************************************
|
|
||||||
async fn validate_signup(form: &SignupForm) -> Result<User, CreateUserError> {
|
|
||||||
let username = form.username.trim();
|
|
||||||
let password = form.password.trim();
|
|
||||||
let verify = form.pw_verify.trim();
|
|
||||||
|
|
||||||
let name_len = username.graphemes(true).size_hint().1.unwrap();
|
|
||||||
// we are not ascii exclusivists around here
|
|
||||||
if !USERNAME_LEN.contains(&name_len) {
|
|
||||||
return Err(CreateUserErrorKind::BadUsername.into());
|
|
||||||
}
|
|
||||||
|
|
||||||
if password != verify {
|
|
||||||
return Err(CreateUserErrorKind::PasswordMismatch.into());
|
|
||||||
}
|
|
||||||
let pwlen = password.graphemes(true).size_hint().1.unwrap_or(0);
|
|
||||||
if !PASSWORD_LEN.contains(&pwlen) {
|
|
||||||
return Err(CreateUserErrorKind::BadPassword.into());
|
|
||||||
}
|
|
||||||
|
|
||||||
// clean up the optionals
|
|
||||||
let displayname = validate_optional_length(
|
|
||||||
&form.displayname,
|
|
||||||
DISPLAYNAME_LEN,
|
|
||||||
CreateUserErrorKind::BadDisplayname,
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let email = validate_length(&form.email, EMAIL_LEN, CreateUserErrorKind::BadEmail)?;
|
|
||||||
|
|
||||||
let user = User {
|
|
||||||
username: username.to_string(),
|
|
||||||
displayname,
|
|
||||||
email,
|
|
||||||
password: password.to_string(),
|
|
||||||
pw_verify: verify.to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(user)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn empty_string_as_none<'de, D, T>(de: D) -> Result<Option<T>, D::Error>
|
|
||||||
where
|
|
||||||
D: serde::Deserializer<'de>,
|
|
||||||
T: std::str::FromStr,
|
|
||||||
T::Err: std::fmt::Display,
|
|
||||||
{
|
|
||||||
let opt = <Option<String> as serde::Deserialize>::deserialize(de)?;
|
|
||||||
match opt.as_deref() {
|
|
||||||
None | Some("") => Ok(None),
|
|
||||||
Some(s) => std::str::FromStr::from_str(s)
|
|
||||||
.map_err(serde::de::Error::custom)
|
|
||||||
.map(Some),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn validate_optional_length<E: Error>(
|
|
||||||
opt: &Option<String>,
|
|
||||||
len_range: RangeInclusive<usize>,
|
|
||||||
err: E,
|
|
||||||
) -> Result<Option<String>, E> {
|
|
||||||
if let Some(opt) = opt {
|
|
||||||
let opt = opt.trim();
|
|
||||||
let len = opt.graphemes(true).size_hint().1.unwrap();
|
|
||||||
if !len_range.contains(&len) {
|
|
||||||
Err(err)
|
|
||||||
} else {
|
|
||||||
Ok(Some(opt.to_string()))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn validate_length<E: Error>(
|
|
||||||
thing: &str,
|
|
||||||
len_range: RangeInclusive<usize>,
|
|
||||||
err: E,
|
|
||||||
) -> Result<String, E> {
|
|
||||||
let thing = thing.trim();
|
|
||||||
let len = thing.graphemes(true).size_hint().1.unwrap();
|
|
||||||
if !len_range.contains(&len) {
|
|
||||||
Err(err)
|
|
||||||
} else {
|
|
||||||
Ok(thing.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
153
src/handlers/handlers.rs
Normal file
153
src/handlers/handlers.rs
Normal file
|
@ -0,0 +1,153 @@
|
||||||
|
use std::ops::RangeInclusive;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Form, Path},
|
||||||
|
response::{IntoResponse, Redirect},
|
||||||
|
};
|
||||||
|
use rand::random;
|
||||||
|
use tower_sessions::Session;
|
||||||
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
|
|
||||||
|
use super::{util, CreateUserError, CreateUserErrorKind, SignupForm};
|
||||||
|
use crate::{
|
||||||
|
templates::*,
|
||||||
|
user::{ForgejoUser, User},
|
||||||
|
};
|
||||||
|
|
||||||
|
const PASSWORD_LEN: RangeInclusive<usize> = 4..=100;
|
||||||
|
const USERNAME_LEN: RangeInclusive<usize> = 1..=50;
|
||||||
|
const DISPLAYNAME_LEN: RangeInclusive<usize> = 0..=100;
|
||||||
|
const EMAIL_LEN: RangeInclusive<usize> = 4..=50;
|
||||||
|
|
||||||
|
lazy_static! {
|
||||||
|
static ref SIGNUP_KEY: String = format!("meow-{}", random::<u128>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Displays the signup form.
|
||||||
|
pub async fn get_signup() -> impl IntoResponse {
|
||||||
|
SignupPage {
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receives the form with the user signup fields filled out.
|
||||||
|
pub async fn post_signup(
|
||||||
|
session: Session,
|
||||||
|
Form(form): Form<SignupForm>,
|
||||||
|
) -> Result<impl IntoResponse, CreateUserError> {
|
||||||
|
let user = validate_signup(&form).await?;
|
||||||
|
session.insert(&SIGNUP_KEY, user).await.unwrap();
|
||||||
|
|
||||||
|
Ok(Redirect::to(
|
||||||
|
"https://buy.stripe.com/test_eVa6rrb7ygjNbwk000",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Redirected from Stripe with the receipt of payment.
|
||||||
|
pub async fn payment_success(session: Session, receipt: Option<Path<String>>) -> impl IntoResponse {
|
||||||
|
let user: User = session.get(&SIGNUP_KEY).await.unwrap().unwrap_or_default();
|
||||||
|
|
||||||
|
if receipt.is_none() {
|
||||||
|
log::info!("Got {:?} from the session, but no receipt.", &user);
|
||||||
|
return CreateUserError(CreateUserErrorKind::BadPayment).into_response();
|
||||||
|
}
|
||||||
|
let Path(receipt) = receipt.unwrap();
|
||||||
|
|
||||||
|
if user == User::default() {
|
||||||
|
log::warn!("Could not find user in session; got receipt {}", receipt);
|
||||||
|
return CreateUserError(CreateUserErrorKind::NoFormFound).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
if !confirm_payment(&receipt) {
|
||||||
|
return CreateUserError(CreateUserErrorKind::BadPayment).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
if !create_user(&user) {
|
||||||
|
return CreateUserError(CreateUserErrorKind::AlreadyExists).into_response();
|
||||||
|
}
|
||||||
|
// TODO: store the receipt into a durable store to prevent re-use after creating
|
||||||
|
// an account
|
||||||
|
|
||||||
|
session.delete().await.unwrap_or_else(|e| {
|
||||||
|
log::error!("Got error deleting {} from session, got {}", &user, e);
|
||||||
|
});
|
||||||
|
|
||||||
|
log::info!("Added {:?}", &user);
|
||||||
|
SignupSuccessPage(user).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
//-************************************************************************
|
||||||
|
// helpers
|
||||||
|
//-************************************************************************
|
||||||
|
fn create_user(user: &User) -> bool {
|
||||||
|
let token = std::env::var("ADMIN_TOKEN").expect("Could not find $ADMIN_TOKEN in environment.");
|
||||||
|
let url = std::env::var("ADD_USER_ENDPOINT")
|
||||||
|
.expect("Could not find $ADD_USER_ENDPOINT in environment");
|
||||||
|
let auth_header = format!("token {token}");
|
||||||
|
let user: ForgejoUser = user.into();
|
||||||
|
let resp = ureq::post(&format!("http://{url}/api/v1/admin/users"))
|
||||||
|
.set("Authorization", &auth_header)
|
||||||
|
.send_json(user)
|
||||||
|
.unwrap();
|
||||||
|
resp.status() == 201
|
||||||
|
}
|
||||||
|
|
||||||
|
fn confirm_payment(stripe_checkout_session_id: &str) -> bool {
|
||||||
|
let token = std::env::var("STRIPE_TOKEN").expect("Could not find $STRIPE_TOKEN in environment");
|
||||||
|
let url = format!("https://api.stripe.com/v1/checkout/sessions/{stripe_checkout_session_id}");
|
||||||
|
let json: serde_json::Value = ureq::get(&url)
|
||||||
|
.set("Authorization", &format!("Bearer {token}"))
|
||||||
|
.call()
|
||||||
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
|
||||||
|
.and_then(|resp| resp.into_json())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// see https://docs.stripe.com/api/checkout/sessions/retrieve
|
||||||
|
let total = json["amount_total"].as_i64().unwrap_or(0);
|
||||||
|
let created_at = json["created"].as_i64().unwrap_or(0);
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let then = chrono::DateTime::from_timestamp(created_at, 0).unwrap();
|
||||||
|
let dur = now - then;
|
||||||
|
let max_elapsed = chrono::TimeDelta::new(12 * 3600, 0).unwrap();
|
||||||
|
|
||||||
|
(dur < max_elapsed) && (total > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validate_signup(form: &SignupForm) -> Result<User, CreateUserError> {
|
||||||
|
let username = form.username.trim();
|
||||||
|
let password = form.password.trim();
|
||||||
|
let verify = form.pw_verify.trim();
|
||||||
|
|
||||||
|
let name_len = username.graphemes(true).size_hint().1.unwrap();
|
||||||
|
// we are not ascii exclusivists around here
|
||||||
|
if !USERNAME_LEN.contains(&name_len) {
|
||||||
|
return Err(CreateUserErrorKind::BadUsername.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
if password != verify {
|
||||||
|
return Err(CreateUserErrorKind::PasswordMismatch.into());
|
||||||
|
}
|
||||||
|
let pwlen = password.graphemes(true).size_hint().1.unwrap_or(0);
|
||||||
|
if !PASSWORD_LEN.contains(&pwlen) {
|
||||||
|
return Err(CreateUserErrorKind::BadPassword.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// clean up the optionals
|
||||||
|
let displayname = util::validate_optional_length(
|
||||||
|
&form.displayname,
|
||||||
|
DISPLAYNAME_LEN,
|
||||||
|
CreateUserErrorKind::BadDisplayname,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let email = util::validate_length(&form.email, EMAIL_LEN, CreateUserErrorKind::BadEmail)?;
|
||||||
|
|
||||||
|
let user = User {
|
||||||
|
username: username.to_string(),
|
||||||
|
displayname,
|
||||||
|
email,
|
||||||
|
password: password.to_string(),
|
||||||
|
pw_verify: verify.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(user)
|
||||||
|
}
|
56
src/handlers/mod.rs
Normal file
56
src/handlers/mod.rs
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
use std::fmt::Debug;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::templates::*;
|
||||||
|
|
||||||
|
pub mod handlers;
|
||||||
|
mod util;
|
||||||
|
|
||||||
|
#[Error(desc = "Could not create user.")]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub struct CreateUserError(#[from] pub CreateUserErrorKind);
|
||||||
|
|
||||||
|
impl IntoResponse for CreateUserError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let kind = self;
|
||||||
|
let mut resp = SignupErrorPage(format!("{kind}")).into_response();
|
||||||
|
*resp.status_mut() = StatusCode::FORBIDDEN;
|
||||||
|
resp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Error]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum CreateUserErrorKind {
|
||||||
|
#[error(desc = "That username already exists")]
|
||||||
|
AlreadyExists,
|
||||||
|
#[error(desc = "Usernames must be between 1 and 50 characters long")]
|
||||||
|
BadUsername,
|
||||||
|
#[error(desc = "Your passwords didn't match")]
|
||||||
|
PasswordMismatch,
|
||||||
|
#[error(desc = "Password must have at least 4 and at most 100 characters")]
|
||||||
|
BadPassword,
|
||||||
|
#[error(desc = "Display name must be less than 100 characters long")]
|
||||||
|
BadDisplayname,
|
||||||
|
#[error(desc = "Your email is too short, it simply can't be real")]
|
||||||
|
BadEmail,
|
||||||
|
#[error(desc = "We could not verify your payment")]
|
||||||
|
BadPayment,
|
||||||
|
#[error(desc = "We couldn't retrieve your info from this browser session")]
|
||||||
|
NoFormFound,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct SignupForm {
|
||||||
|
pub username: String,
|
||||||
|
#[serde(default, deserialize_with = "util::empty_string_as_none")]
|
||||||
|
pub displayname: Option<String>,
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
pub pw_verify: String,
|
||||||
|
}
|
50
src/handlers/util.rs
Normal file
50
src/handlers/util.rs
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
use std::{error::Error, ops::RangeInclusive};
|
||||||
|
|
||||||
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
|
|
||||||
|
pub(crate) fn empty_string_as_none<'de, D, T>(de: D) -> Result<Option<T>, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
T: std::str::FromStr,
|
||||||
|
T::Err: std::fmt::Display,
|
||||||
|
{
|
||||||
|
let opt = <Option<String> as serde::Deserialize>::deserialize(de)?;
|
||||||
|
match opt.as_deref() {
|
||||||
|
None | Some("") => Ok(None),
|
||||||
|
Some(s) => std::str::FromStr::from_str(s)
|
||||||
|
.map_err(serde::de::Error::custom)
|
||||||
|
.map(Some),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_optional_length<E: Error>(
|
||||||
|
opt: &Option<String>,
|
||||||
|
len_range: RangeInclusive<usize>,
|
||||||
|
err: E,
|
||||||
|
) -> Result<Option<String>, E> {
|
||||||
|
if let Some(opt) = opt {
|
||||||
|
let opt = opt.trim();
|
||||||
|
let len = opt.graphemes(true).size_hint().1.unwrap();
|
||||||
|
if !len_range.contains(&len) {
|
||||||
|
Err(err)
|
||||||
|
} else {
|
||||||
|
Ok(Some(opt.to_string()))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_length<E: Error>(
|
||||||
|
thing: &str,
|
||||||
|
len_range: RangeInclusive<usize>,
|
||||||
|
err: E,
|
||||||
|
) -> Result<String, E> {
|
||||||
|
let thing = thing.trim();
|
||||||
|
let len = thing.graphemes(true).size_hint().1.unwrap();
|
||||||
|
if !len_range.contains(&len) {
|
||||||
|
Err(err)
|
||||||
|
} else {
|
||||||
|
Ok(thing.to_string())
|
||||||
|
}
|
||||||
|
}
|
39
src/main.rs
39
src/main.rs
|
@ -1,9 +1,14 @@
|
||||||
use std::{io::Write, net::SocketAddr};
|
use std::{
|
||||||
|
env::VarError,
|
||||||
|
io::Write,
|
||||||
|
net::{Ipv4Addr, SocketAddr},
|
||||||
|
};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
routing::{get, MethodRouter},
|
routing::{get, MethodRouter},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
use tower_http::services::ServeDir;
|
use tower_http::services::ServeDir;
|
||||||
use tower_sessions::{Expiry, MemoryStore, SessionManagerLayer};
|
use tower_sessions::{Expiry, MemoryStore, SessionManagerLayer};
|
||||||
|
|
||||||
|
@ -14,16 +19,14 @@ extern crate justerror;
|
||||||
extern crate lazy_static;
|
extern crate lazy_static;
|
||||||
|
|
||||||
mod handlers;
|
mod handlers;
|
||||||
use handlers::{get_signup, payment_success, post_signup};
|
|
||||||
|
|
||||||
mod templates;
|
mod templates;
|
||||||
|
|
||||||
mod user;
|
mod user;
|
||||||
use user::User;
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
|
use handlers::handlers::{get_signup, payment_success, post_signup};
|
||||||
init();
|
init();
|
||||||
|
|
||||||
// for javascript and css
|
// for javascript and css
|
||||||
let assets_dir = std::env::current_dir().unwrap().join("assets");
|
let assets_dir = std::env::current_dir().unwrap().join("assets");
|
||||||
let assets_svc = ServeDir::new(assets_dir.as_path());
|
let assets_svc = ServeDir::new(assets_dir.as_path());
|
||||||
|
@ -32,7 +35,7 @@ async fn main() {
|
||||||
let session_store = MemoryStore::default();
|
let session_store = MemoryStore::default();
|
||||||
let session_layer = SessionManagerLayer::new(session_store)
|
let session_layer = SessionManagerLayer::new(session_store)
|
||||||
.with_secure(true)
|
.with_secure(true)
|
||||||
.with_expiry(Expiry::OnInactivity(time::Duration::hours(16)));
|
.with_expiry(Expiry::OnInactivity(time::Duration::hours(2)));
|
||||||
|
|
||||||
// the core application, defining the routes and handlers
|
// the core application, defining the routes and handlers
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
|
@ -42,24 +45,38 @@ async fn main() {
|
||||||
.route("/payment_success/:receipt", get(payment_success))
|
.route("/payment_success/:receipt", get(payment_success))
|
||||||
.layer(session_layer)
|
.layer(session_layer)
|
||||||
.into_make_service();
|
.into_make_service();
|
||||||
|
let listener = mklistener().await;
|
||||||
// listening on the network
|
|
||||||
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
|
|
||||||
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
|
||||||
axum::serve(listener, app).await.unwrap();
|
axum::serve(listener, app).await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//-************************************************************************
|
||||||
|
// li'l helpers
|
||||||
|
//-************************************************************************
|
||||||
fn init() {
|
fn init() {
|
||||||
dotenvy::dotenv().expect("Could not read .env file.");
|
dotenvy::dotenv().expect("Could not read .env file.");
|
||||||
env_logger::builder()
|
env_logger::builder()
|
||||||
.format(|buf, record| {
|
.format(|buf, record| {
|
||||||
//
|
|
||||||
let ts = buf.timestamp();
|
let ts = buf.timestamp();
|
||||||
writeln!(buf, "{}: {}", ts, record.args())
|
writeln!(buf, "{}: {}", ts, record.args())
|
||||||
})
|
})
|
||||||
.init();
|
.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn mklistener() -> TcpListener {
|
||||||
|
let ip =
|
||||||
|
std::env::var("LISTENING_ADDR").expect("Could not find $LISTENING_ADDR in environment");
|
||||||
|
let ip: Ipv4Addr = ip
|
||||||
|
.parse()
|
||||||
|
.unwrap_or_else(|_| panic!("Could not parse {ip} as an IP address"));
|
||||||
|
let port: u16 = std::env::var("LISTENING_PORT")
|
||||||
|
.and_then(|p| p.parse().map_err(|_| VarError::NotPresent))
|
||||||
|
.unwrap_or_else(|_| {
|
||||||
|
panic!("Could not find LISTENING_PORT in env or parse if present");
|
||||||
|
});
|
||||||
|
let addr = SocketAddr::from((ip, port));
|
||||||
|
TcpListener::bind(&addr).await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
/// Adds both routes, with and without a trailing slash.
|
/// Adds both routes, with and without a trailing slash.
|
||||||
trait RouterPathStrip<S>
|
trait RouterPathStrip<S>
|
||||||
where
|
where
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::User;
|
use crate::user::User;
|
||||||
|
|
||||||
#[derive(Debug, Default, Template, Deserialize, Serialize, PartialEq, Eq)]
|
#[derive(Debug, Default, Template, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
#[template(path = "signup.html")]
|
#[template(path = "signup.html")]
|
||||||
|
|
23
src/user.rs
23
src/user.rs
|
@ -2,7 +2,7 @@ use std::fmt::{Debug, Display};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, Default)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub displayname: Option<String>,
|
pub displayname: Option<String>,
|
||||||
|
@ -43,3 +43,24 @@ impl Display for User {
|
||||||
write!(f, "Username: {uname}\nDisplayname: {dname}\nEmail: {email}")
|
write!(f, "Username: {uname}\nDisplayname: {dname}\nEmail: {email}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Eq, Deserialize, Serialize, Default)]
|
||||||
|
pub struct ForgejoUser<'u> {
|
||||||
|
pub username: &'u str,
|
||||||
|
pub full_name: Option<&'u str>,
|
||||||
|
pub email: &'u str,
|
||||||
|
pub password: &'u str,
|
||||||
|
pub must_change_password: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'u> From<&'u User> for ForgejoUser<'u> {
|
||||||
|
fn from(user: &'u User) -> Self {
|
||||||
|
Self {
|
||||||
|
username: &user.username,
|
||||||
|
full_name: user.displayname.as_deref(),
|
||||||
|
email: &user.email,
|
||||||
|
password: &user.password,
|
||||||
|
must_change_password: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue