sum more bullshit

This commit is contained in:
CanadianBaconBoi 2026-08-21 12:24:52 +02:00
parent 8f5bc07070
commit d83f044dcd
38 changed files with 1040 additions and 170 deletions

View File

@ -5,6 +5,7 @@
<sourceFolder url="file://$MODULE_DIR$/backend/src" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/backend/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/web/src" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$/web/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/web/procmacros/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/backend/target" /> <excludeFolder url="file://$MODULE_DIR$/backend/target" />
<excludeFolder url="file://$MODULE_DIR$/target" /> <excludeFolder url="file://$MODULE_DIR$/target" />
<excludeFolder url="file://$MODULE_DIR$/web/target" /> <excludeFolder url="file://$MODULE_DIR$/web/target" />

View File

@ -1,11 +1,11 @@
#![allow(async_fn_in_trait)] #![allow(async_fn_in_trait)]
use crate::cloudflare::CloudflareDnsProvider;
use derive_more::Display;
use std::fmt::Debug; use std::fmt::Debug;
use std::str::FromStr; use std::str::FromStr;
use std::sync::Arc; use std::sync::Arc;
use derive_more::Display; use tokio::sync::RwLock;
use tokio::sync::{Mutex, RwLock};
use crate::cloudflare::CloudflareDnsProvider;
pub mod cloudflare; pub mod cloudflare;
@ -127,7 +127,7 @@ impl DnsProviderType {
Ok(Self::Cloudflare(Arc::new(RwLock::new(CloudflareDnsProvider::new(api_key, zone_id)?)))) Ok(Self::Cloudflare(Arc::new(RwLock::new(CloudflareDnsProvider::new(api_key, zone_id)?))))
} }
pub fn as_provider(&mut self) -> &mut Arc<RwLock<impl DnsProvider>> { pub fn as_provider(&mut self) -> &mut Arc<RwLock<impl DnsProvider<'_>>> {
match self { match self {
Self::Cloudflare(provider) => provider, Self::Cloudflare(provider) => provider,
} }

2
rust-toolchain.toml Normal file
View File

@ -0,0 +1,2 @@
[toolchain]
channel = "nightly"

22
web/assets/_hyperscript.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -4,8 +4,10 @@ CREATE TABLE users (
username TEXT UNIQUE NOT NULL, username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL, email TEXT UNIQUE NOT NULL,
email_confirmed_at TIMESTAMPTZ, email_confirmed_at TIMESTAMPTZ,
password_reset_requested_at TIMESTAMPTZ,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
settings_unlocked_at TIMESTAMPTZ,
two_factor_secret TEXT two_factor_secret TEXT
); );

View File

@ -1,10 +1,6 @@
#![feature(str_as_str)]
#![feature(associated_type_defaults)]
mod dns; mod dns;
mod web; mod web;
use tokio::signal;
use microtld_backend::DnsProvider; use microtld_backend::DnsProvider;
use crate::web::service::WebService; use crate::web::service::WebService;
@ -29,4 +25,4 @@ async fn main() -> anyhow::Result<()> {
println!("Listening on {}", listen_url); println!("Listening on {}", listen_url);
web_service.run().await web_service.run().await
} }

View File

@ -1,6 +1,5 @@
use maud::DOCTYPE;
use crate::web::content::style;
use super::*; use super::*;
use crate::web::content::style;
pub fn head(page_title: &str, page_slug: &str) -> Markup { pub fn head(page_title: &str, page_slug: &str) -> Markup {
let css = style::stylesheet(); let css = style::stylesheet();
@ -14,6 +13,7 @@ pub fn head(page_title: &str, page_slug: &str) -> Markup {
link rel="icon" href="/static/favicon.ico"; link rel="icon" href="/static/favicon.ico";
script src="/assets/htmx.min.js" defer {} script src="/assets/htmx.min.js" defer {}
script src="/assets/_hyperscript.min.js" defer {}
meta name="viewport" content="width=device-width, initial-scale=1"; meta name="viewport" content="width=device-width, initial-scale=1";
} // TODO: Drnk monter } // TODO: Drnk monter

View File

@ -58,10 +58,11 @@ pub fn header(is_auth: bool) -> Markup {
a.link href="/status" { "Status" } a.link href="/status" { "Status" }
div class="spacer" {} div class="spacer" {}
@if is_auth { @if is_auth {
button.link href="/logout" class="auth-shown" hx-get="/auth/logoutprompt" hx-target="#modal-content" hx-swap="innerHTML" popovertarget="modal" { "Logout" } a.link href="/profile" { "Profile" }
a.link href="/auth/logoutprompt" hx-get="/auth/logoutprompt" hx-target="#modal-content" hx-swap="innerHTML" { "Logout" }
} @else { } @else {
button.link href="/login" class="auth-hidden" hx-get="/auth/loginprompt" hx-target="#modal-content" hx-swap="innerHTML" popovertarget="modal" { "Login" } a.link href="/auth/loginprompt" hx-get="/auth/loginprompt" hx-target="#modal-content" hx-swap="innerHTML" { "Login" }
button.link href="/register" class="auth-hidden" hx-get="/auth/registerprompt" hx-target="#modal-content" hx-swap="innerHTML" popovertarget="modal" { "Register" } a.link href="/auth/registerprompt" hx-get="/auth/registerprompt" hx-target="#modal-content" hx-swap="innerHTML" { "Register" }
} }
a.link href="https://github.com/microtld/microtld" {img src="/assets/img/github-mark.svg";} a.link href="https://github.com/microtld/microtld" {img src="/assets/img/github-mark.svg";}
} }

View File

@ -4,4 +4,5 @@ pub mod head;
pub mod header; pub mod header;
pub mod footer; pub mod footer;
pub mod page; pub mod page;
pub mod profilesettings;

View File

@ -1,3 +1,5 @@
use axum::http::HeaderValue;
use axum::response::IntoResponse;
use maud::DOCTYPE; use maud::DOCTYPE;
use crate::web::content::partials::footer::footer; use crate::web::content::partials::footer::footer;
use crate::web::content::partials::head::head; use crate::web::content::partials::head::head;
@ -54,6 +56,35 @@ inventory::submit! {
margin-top: 1rem; margin-top: 1rem;
} }
} }
span {
display: flex;
flex-direction: row;
gap: 0.5rem;
}
a, button {
background: var(--border);
text-decoration: none;
color: var(--nav-link);
font-weight: 600;
border: none;
margin-top: 1rem;
padding: 0.25rem 0.5rem;
cursor: pointer;
font: inherit;
}
a:hover, button:hover {
background: color-mix(in srgb, var(--border) 50%, transparent);
transition: background 0.2s ease-in-out;
}
} }
> .modal-close { > .modal-close {
@ -87,7 +118,7 @@ inventory::submit! {
) )
} }
pub fn show_error(message: &str) -> Markup { pub fn show_error(message: &str) -> impl IntoResponse {
html! { html! {
h1 { (message) } h1 { (message) }
} }
@ -104,7 +135,7 @@ pub fn page(page_title: &str, page_slug: &str, is_auth: bool, content: Markup) -
(content) (content)
} }
div popover id="modal" { div popover id="modal" _="on htmx:afterSwap call my.showPopover()" {
div id="modal-content" {} div id="modal-content" {}
button class="modal-close" popovertarget="modal" popovertargetaction="hide" { "Close" } button class="modal-close" popovertarget="modal" popovertargetaction="hide" { "Close" }
} }

View File

@ -0,0 +1,82 @@
use maud::{html, Markup};
pub fn profilesettings(locked: bool, errormsg: Option<&str>, username: &str, email: &str) -> Markup {
html! {
h3 {
"Settings are: "
span id="settings-state" {
@if locked { "Locked" } @else { "Unlocked" }
}
}
@if let Some(errormsg) = errormsg {
p { (errormsg) }
}
{
@if locked {
table {
tr {
td colspan="3" {
form class="gridform-h" hx-post="/profile/update-settings" hx-target="#profilesettings" hx-swap="innerHTML" {
label for="username" { "Username" }
input type="text" name="username" value=(username) disabled;
input type="submit" value="Update" disabled;
}
}
}
tr {
td colspan="3" {
form class="gridform-h" hx-post="/profile/update-settings" hx-target="#profilesettings" hx-swap="innerHTML" {
label for="email" { "Email" }
input type="email" name="email" value=(email) disabled;
input type="submit" value="Update" disabled;
}
}
}
tr {
td colspan="3" {
form class="gridform-h" hx-post="/profile/update-settings" hx-target="#profilesettings" hx-swap="innerHTML" {
label for="password" { "Password" }
input type="password" name="password" disabled;
input type="submit" value="Update" disabled;
}
}
}
}
} @else {
table {
tr {
td colspan="3" {
form class="gridform-h" hx-post="/profile/update-settings" hx-target="#profilesettings" hx-swap="innerHTML" {
label for="username" { "Username" }
input type="text" name="username" value=(username);
input type="submit" value="Update";
}
}
}
tr {
td colspan="3" {
form class="gridform-h" hx-post="/profile/update-settings" hx-target="#profilesettings" hx-swap="innerHTML" {
label for="email" { "Email" }
input type="email" name="email" value=(email);
input type="submit" value="Update";
}
}
}
tr {
td colspan="3" {
form class="gridform-h" hx-post="/profile/update-settings" hx-target="#profilesettings" hx-swap="innerHTML" {
label for="password" { "Password" }
input type="password" name="password";
input type="submit" value="Update";
}
}
}
}
}
}
}
}

View File

@ -1,3 +1,2 @@
pub(crate) mod content; pub(crate) mod content;
pub(crate) mod htmx;
pub(crate) mod service; pub(crate) mod service;

View File

@ -1,6 +1,9 @@
use argon2::password_hash::rand_core::{OsRng, RngCore}; use argon2::password_hash::rand_core::{OsRng, RngCore};
use base64::Engine;
use base64::prelude::BASE64_URL_SAFE;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use sqlx::{Error, PgPool}; use sqlx::{Error, PgPool};
use sqlx::postgres::PgQueryResult;
use sqlx::types::Json; use sqlx::types::Json;
use time::{OffsetDateTime, Duration}; use time::{OffsetDateTime, Duration};
use uuid::Uuid; use uuid::Uuid;
@ -47,13 +50,20 @@ impl Confirmation {
} }
} }
pub async fn get_by_identifier(identifier: &Vec<u8>, db: &PgPool) -> anyhow::Result<Option<Confirmation>> {
sqlx::query_as::<_, Confirmation>("SELECT * FROM confirmations WHERE identifier = $1")
.bind(identifier)
.fetch_optional(db)
.await.map_err(|e| anyhow::anyhow!("Database error: {}", e))
}
/// for use in http://localhost:3000/auth/confirm?token=<token> /// for use in http://localhost:3000/auth/confirm?token=<token>
pub fn get_url_token(&self) -> anyhow::Result<String> { pub fn get_url_token(&self) -> anyhow::Result<String> {
Ok(base64::encode([self.identifier.as_ref(), self.verifier.ok_or_else(||anyhow::anyhow!("Cannot construct url token from non-original confirmation"))?.as_ref()].concat())) Ok(BASE64_URL_SAFE.encode([self.identifier.as_ref(), self.verifier.ok_or_else(||anyhow::anyhow!("Cannot construct url token from non-original confirmation"))?.as_ref()].concat()))
} }
pub fn decode_url_token(token: &str) -> anyhow::Result<(Vec<u8>, Vec<u8>)> { pub fn decode_url_token(token: &str) -> anyhow::Result<(Vec<u8>, Vec<u8>)> {
let decoded_token = base64::decode(token)?; let decoded_token = BASE64_URL_SAFE.decode(token)?;
let (identifier, verifier) = decoded_token.split_at(16); let (identifier, verifier) = decoded_token.split_at(16);
Ok((identifier.to_vec(), verifier.to_vec())) Ok((identifier.to_vec(), verifier.to_vec()))
} }
@ -96,6 +106,31 @@ impl Confirmation {
} }
} }
pub async fn delete(self, db: &PgPool) -> anyhow::Result<PgQueryResult> {
sqlx::query("DELETE FROM confirmations WHERE identifier = $1")
.bind(&self.identifier)
.execute(db)
.await
.map_err(|e| anyhow::anyhow!("Database error: {}", e))
}
pub async fn delete_all_for_user(user_id: Uuid, db: &PgPool) -> anyhow::Result<PgQueryResult> {
sqlx::query("DELETE FROM confirmations WHERE user_id = $1")
.bind(user_id)
.execute(db)
.await
.map_err(|e| anyhow::anyhow!("Database error: {}", e))
}
pub async fn delete_all_for_user_with_type(user_id: Uuid, action_type: &str, db: &PgPool) -> anyhow::Result<PgQueryResult> {
sqlx::query("DELETE FROM confirmations WHERE user_id = $1 AND action_type = $2")
.bind(user_id)
.bind(action_type)
.execute(db)
.await
.map_err(|e| anyhow::anyhow!("Database error: {}", e))
}
pub fn is_expired(&self) -> bool { pub fn is_expired(&self) -> bool {
self.expires_at < OffsetDateTime::now_utc() self.expires_at < OffsetDateTime::now_utc()
} }

View File

@ -57,3 +57,12 @@ pub async fn verify_password_async(
.map_err(|e|anyhow::anyhow!("Failed to verify password: {}", e)) .map_err(|e|anyhow::anyhow!("Failed to verify password: {}", e))
} }
pub fn ensure_password_strength(password: &str) -> Result<(), String> {
if password.len() < 10 || password.len() > 128 {
return Err("Password must be 10 to 128 characters".into());
}
if password.chars().any(|c| !c.is_ascii_alphanumeric() && !c.is_ascii_punctuation()) {
return Err("Password must contain only ASCII alphanumeric/punctuation characters".into());
}
Ok(())
}

View File

@ -1,5 +1,8 @@
use lightningcss::traits::Op;
use maud::html;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::{Error, PgPool}; use sqlx::{Error, PgPool};
use sqlx::postgres::PgQueryResult;
use time::{Duration, OffsetDateTime}; use time::{Duration, OffsetDateTime};
use uuid::Uuid; use uuid::Uuid;
use crate::web::service::auth::confirmation::Confirmation; use crate::web::service::auth::confirmation::Confirmation;
@ -17,13 +20,36 @@ pub struct User {
pub username: String, pub username: String,
pub email: String, pub email: String,
pub email_confirmed_at: Option<OffsetDateTime>, pub email_confirmed_at: Option<OffsetDateTime>,
pub password_reset_requested_at: Option<OffsetDateTime>,
pub password_hash: String, pub password_hash: String,
pub created_at: OffsetDateTime, pub created_at: OffsetDateTime,
pub updated_at: OffsetDateTime, pub updated_at: OffsetDateTime,
pub settings_unlocked_at: Option<OffsetDateTime>,
pub two_factor_secret: Option<String>, pub two_factor_secret: Option<String>,
} }
impl User { impl User {
pub async fn get_user_by_name(username: &str, db: &PgPool) -> anyhow::Result<Option<Self>> {
sqlx::query_as::<_, User>("SELECT * FROM users WHERE username = $1")
.bind(username)
.fetch_optional(db)
.await.map_err(|e| anyhow::Error::msg(e.to_string()))
}
pub async fn get_user_by_email(email: &str, db: &PgPool) -> anyhow::Result<Option<Self>> {
sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1")
.bind(email)
.fetch_optional(db)
.await.map_err(|e| anyhow::Error::msg(e.to_string()))
}
pub async fn get_user_by_id(id: Uuid, db: &PgPool) -> anyhow::Result<Option<Self>> {
sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
.bind(id)
.fetch_optional(db)
.await.map_err(|e| anyhow::Error::msg(e.to_string()))
}
pub async fn register(username: String, email: String, password_hash: String, db: &PgPool) -> Result<Self, RegistrationError> { pub async fn register(username: String, email: String, password_hash: String, db: &PgPool) -> Result<Self, RegistrationError> {
// ON CONFLICT DO NOTHING prevents errors on duplicate email // ON CONFLICT DO NOTHING prevents errors on duplicate email
// without revealing whether the email already exists // without revealing whether the email already exists
@ -41,6 +67,7 @@ impl User {
.fetch_optional(db) .fetch_optional(db)
.await; .await;
println!("{:?}", result);
match result { match result {
Ok(u) => { Ok(u) => {
if let Some(u) = u { if let Some(u) = u {
@ -63,6 +90,73 @@ impl User {
} }
} }
} }
pub async fn update_password(&mut self, password_hash: &str, db: &PgPool) -> anyhow::Result<PgQueryResult> {
sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
.bind(&password_hash)
.bind(&self.id)
.execute(db)
.await.map_err(|e| anyhow::Error::msg(e.to_string()))
}
pub async fn update_email(&mut self, email: &str, db: &PgPool) -> anyhow::Result<PgQueryResult> {
sqlx::query("UPDATE users SET email = $1 WHERE id = $2")
.bind(&email)
.bind(&self.id)
.execute(db)
.await.map_err(|e| anyhow::Error::msg(e.to_string()))
}
pub async fn update_username(&mut self, username: &str, db: &PgPool) -> anyhow::Result<PgQueryResult> {
sqlx::query("UPDATE users SET username = $1 WHERE id = $2")
.bind(&username)
.bind(&self.id)
.execute(db)
.await.map_err(|e| anyhow::Error::msg(e.to_string()))
}
pub fn is_settings_unlocked(&self) -> bool {
if let Some(unlocked_at) = self.settings_unlocked_at && OffsetDateTime::now_utc() - unlocked_at < Duration::minutes(5) {
true
} else {
false
}
}
pub async fn set_settings_unlocked(&mut self, unlocked: bool, db: &PgPool) -> anyhow::Result<PgQueryResult> {
if unlocked {
self.settings_unlocked_at = Some(OffsetDateTime::now_utc());
sqlx::query("UPDATE users SET settings_unlocked_at = $1 WHERE id = $2")
.bind(&self.settings_unlocked_at)
.bind(&self.id)
.execute(db)
.await.map_err(|e| anyhow::Error::msg(e.to_string()))
} else {
self.settings_unlocked_at = None;
sqlx::query("UPDATE users SET settings_unlocked_at = NULL WHERE id = $1")
.bind(&self.id)
.execute(db)
.await.map_err(|e| anyhow::Error::msg(e.to_string()))
}
}
pub async fn set_email_confirmed(&mut self, db: &PgPool) -> anyhow::Result<PgQueryResult> {
self.email_confirmed_at = Some(OffsetDateTime::now_utc());
sqlx::query("UPDATE users SET email_confirmed_at = $1 WHERE id = $2")
.bind(&self.email_confirmed_at)
.bind(&self.id)
.execute(db)
.await.map_err(|e| anyhow::Error::msg(e.to_string()))
}
pub async fn set_password_reset_requested(&mut self, db: &PgPool) -> anyhow::Result<PgQueryResult> {
self.password_reset_requested_at = Some(OffsetDateTime::now_utc());
sqlx::query("UPDATE users SET password_reset_requested_at = $1 WHERE id = $2")
.bind(&self.password_reset_requested_at)
.bind(&self.id)
.execute(db)
.await.map_err(|e| anyhow::Error::msg(e.to_string()))
}
} }
pub enum RegistrationError { pub enum RegistrationError {

View File

@ -1,12 +1,11 @@
use crate::web::service::auth::user::User;
use crate::web::service::AppState;
use axum::{ use axum::{
extract::FromRequestParts, extract::FromRequestParts,
http::{request::Parts, StatusCode}, http::{request::Parts, StatusCode},
}; };
use sqlx::PgPool;
use tower_sessions::Session; use tower_sessions::Session;
use uuid::Uuid; use uuid::Uuid;
use crate::web::service::AppState;
use crate::web::service::auth::user::User;
pub struct AuthUser(pub User); pub struct AuthUser(pub User);
@ -27,10 +26,7 @@ impl FromRequestParts<AppState> for AuthUser {
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::UNAUTHORIZED)?; .ok_or(StatusCode::UNAUTHORIZED)?;
let user: User = sqlx::query_as("SELECT * FROM users WHERE id = $1") let user: User = User::get_user_by_id(user_id, &state.db).await
.bind(user_id)
.fetch_optional(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::UNAUTHORIZED)?; .ok_or(StatusCode::UNAUTHORIZED)?;

View File

@ -1,11 +0,0 @@
use serde::Deserialize;
#[derive(Deserialize)]
struct DnsRecordRequest {
name: Option<String>,
target: Option<String>,
ttl: Option<u32>,
record_type: Option<String>,
priority: Option<u16>,
}

View File

@ -1,2 +1 @@
pub mod dns; pub mod authuser;
mod authuser;

View File

@ -19,7 +19,7 @@ pub struct WebService {
pub struct AppState { pub struct AppState {
db: PgPool, db: PgPool,
config: AppConfig, config: AppConfig,
micro_tld: MicroTld micro_tld: MicroTld,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -47,7 +47,7 @@ impl WebService {
let state = AppState { let state = AppState {
db: pool, db: pool,
config, micro_tld, config, micro_tld
}; };
let router = routes::apply_routes(axum::Router::new(), state.db.clone()).await.with_state(state.clone()); let router = routes::apply_routes(axum::Router::new(), state.db.clone()).await.with_state(state.clone());

View File

@ -1,10 +1,11 @@
use axum::extract::{Query, State}; use axum::extract::{Query, State};
use axum::response::IntoResponse; use axum::response::IntoResponse;
use maud::html;
use serde::Deserialize; use serde::Deserialize;
use sha2::Digest; use sha2::Digest;
use subtle::ConstantTimeEq; use subtle::ConstantTimeEq;
use time::OffsetDateTime;
use tower_sessions::Session; use tower_sessions::Session;
use crate::web::content::partials::page::{page, show_error};
use crate::web::service::AppState; use crate::web::service::AppState;
use crate::web::service::auth::confirmation::Confirmation; use crate::web::service::auth::confirmation::Confirmation;
use crate::web::service::auth::user::User; use crate::web::service::auth::user::User;
@ -15,81 +16,103 @@ pub(crate) struct Confirm {
token: String token: String
} }
impl Route<'_, Query<Confirm>> for Confirm { impl Route<'_, (), Query<Confirm>> for Confirm {
const PATH: &'static str = "/confirm"; const PATH: &'static str = "/confirm";
async fn process(session: Session, state: State<AppState>, query: Query<Confirm>) -> impl IntoResponse { async fn process(_: (), session: Session, state: State<AppState>, query: Query<Confirm>) -> impl IntoResponse {
if session.is_empty().await {
return "Not logged in".into_response();
}
let (identifier, verifier) = Confirmation::decode_url_token(&query.token).unwrap(); let (identifier, verifier) = Confirmation::decode_url_token(&query.token).unwrap();
let verifier_hash = sha2::Sha256::digest(&verifier).0; let verifier_hash = sha2::Sha256::digest(&verifier).0;
let confirmation: Option<Confirmation> = sqlx::query_as("SELECT * FROM confirmations WHERE identifier = $1") let confirmation: Option<Confirmation> =
.bind(&identifier) Confirmation::get_by_identifier(&identifier, &state.db).await.unwrap_or(None);
.fetch_optional(&state.db)
.await
.unwrap_or(None);
match confirmation { let content = match confirmation {
Some(confirmation) => { Some(confirmation) => {
if confirmation.action_type != "email_confirmation" { if confirmation.action_type != "email_confirmation" {
return "Invalid confirmation type".into_response(); html! {
} h1 {
"Invalid confirmation type"
if confirmation.is_expired() {
return "Confirmation expired".into_response();
}
match verifier_hash.ct_eq(&confirmation.verifier_hash).unwrap_u8() {
1 => {
let user: Option<User> = sqlx::query_as("SELECT * FROM users WHERE id = $1")
.bind(confirmation.user_id)
.fetch_optional(&state.db)
.await.unwrap_or(None);
match user {
Some(mut user) => {
user.email_confirmed_at = Some(OffsetDateTime::now_utc());
match sqlx::query("UPDATE users SET email_confirmed_at = $1 WHERE id = $2")
.bind(&user.email_confirmed_at)
.bind(&user.id)
.execute(&state.db)
.await {
Ok(_) => {
match sqlx::query("DELETE FROM confirmations WHERE identifier = $1")
.bind(&identifier)
.execute(&state.db)
.await {
Ok(_) => {
"Email confirmed".into_response()
}
Err(e) => {
"Error confirming email".into_response()
}
}
},
Err(e) => {
"Error confirming email".into_response()
}
}
},
None => {
"User not found".into_response()
}
} }
} }
_ => { } else if confirmation.is_expired() {
"Invalid token".into_response() html! {
h1 {
"Confirmation expired"
}
}
} else {
match verifier_hash.ct_eq(&confirmation.verifier_hash).unwrap_u8() {
1 => {
let user: Option<User> = User::get_user_by_id(confirmation.user_id, &state.db).await.unwrap_or(None);
match user {
Some(mut user) => {
if Confirmation::delete_all_for_user_with_type(user.id, "email_confirmation", &state.db).await.is_err() {
html! {
h1 {
"Error confirming email"
}
}
} else {
match user.set_email_confirmed(&state.db).await {
Ok(_) => {
match confirmation.delete(&state.db).await {
Ok(_) => {
html! {
h1 {
"Email confirmed"
}
}
}
Err(e) => {
tracing::error!("Error deleting confirmation: {}", e);
html! {
h1 {
"Error confirming email"
}
}
}
}
},
Err(e) => {
tracing::error!("Error updating user: {}", e);
html! {
h1 {
"Error confirming email"
}
}
}
}
}
},
None => {
html! {
h1 {
"User not found"
}
}
}
}
}
_ => {
html! {
h1 {
"Invalid token"
}
}
}
} }
} }
} }
None => { None => {
"Confirmation not found".into_response() html! {
h1 {
"Confirmation not found"
}
}
} }
} };
page(Self::NAME, Self::PATH, !session.is_empty().await, content)
} }
} }

View File

@ -1,13 +1,13 @@
use axum::extract::{Form, State};
use axum::http::{HeaderValue, StatusCode};
use axum::response::{IntoResponse, Redirect};
use serde::Deserialize;
use tower_sessions::Session;
use crate::web::content::partials::page::show_error; use crate::web::content::partials::page::show_error;
use crate::web::service::AppState; use crate::web::service::auth::hash::{hash_password_async, verify_password_async};
use crate::web::service::auth::user::User; use crate::web::service::auth::user::User;
use crate::web::service::routes::Route; use crate::web::service::routes::Route;
use crate::web::service::auth::hash::{hash_password_async, verify_password_async}; use crate::web::service::AppState;
use axum::extract::{Form, State};
use axum::http::{HeaderValue, StatusCode};
use axum::response::IntoResponse;
use serde::Deserialize;
use tower_sessions::Session;
#[derive(Deserialize, Default)] #[derive(Deserialize, Default)]
pub(crate) struct Login { pub(crate) struct Login {
@ -15,15 +15,11 @@ pub(crate) struct Login {
password: String, password: String,
} }
impl Route<'_, Form<Login>> for Login { impl Route<'_, (), Form<Login>> for Login {
const PATH: &'static str = "/login"; const PATH: &'static str = "/login";
async fn process(session: Session, state: State<AppState>, Form(form): Form<Login>) -> impl IntoResponse { async fn process(_: (), session: Session, state: State<AppState>, Form(form): Form<Login>) -> impl IntoResponse {
let user: Option<User> = sqlx::query_as("SELECT * FROM users WHERE username = $1") let user: Option<User> = User::get_user_by_name(&form.username, &state.db).await.unwrap_or(None);
.bind(&form.username)
.fetch_optional(&state.db)
.await
.unwrap_or(None);
let Some(user) = user else { let Some(user) = user else {
println!("User not found"); println!("User not found");
@ -32,6 +28,11 @@ impl Route<'_, Form<Login>> for Login {
return show_error("Invalid email or password").into_response(); return show_error("Invalid email or password").into_response();
}; };
if user.email_confirmed_at.is_none() {
println!("Email not confirmed");
return show_error("Email not confirmed").into_response();
}
if verify_password_async(form.password, user.password_hash.clone(), state.config.pepper.clone()) if verify_password_async(form.password, user.password_hash.clone(), state.config.pepper.clone())
.await .await
.is_err() .is_err()
@ -52,7 +53,7 @@ impl Route<'_, Form<Login>> for Login {
// Validate redirect target if using a ?next= parameter. // Validate redirect target if using a ?next= parameter.
// Only allow relative paths. Reject absolute URLs to prevent open redirects. // Only allow relative paths. Reject absolute URLs to prevent open redirects.
let mut response = StatusCode::OK.into_response(); let mut response = StatusCode::OK.into_response();
response.headers_mut().insert("HX-Redirect", HeaderValue::from_str("/").unwrap()); response.headers_mut().insert("HX-Redirect", HeaderValue::from_static("/"));
response response
} }
} }

View File

@ -7,13 +7,13 @@ use crate::web::service::routes::Route;
pub(crate) struct LoginPrompt; pub(crate) struct LoginPrompt;
impl Route<'_, ()> for LoginPrompt { impl Route<'_, (), ()> for LoginPrompt {
const PATH: &'static str = "/loginprompt"; const PATH: &'static str = "/loginprompt";
async fn process(_session: Session, _state: State<AppState>, _query: ()) -> impl IntoResponse { async fn process(_: (), _session: Session, _state: State<AppState>, _query: ()) -> impl IntoResponse {
html! { html! {
h1 { "Login" } h1 { "Login" }
form hx-post="/auth/login" hx-target="#modal-content" hx-swap="innerHTML" { form method="post" action="/auth/login" hx-post="/auth/login" hx-target="#modal-content" hx-swap="innerHTML" {
label for="username" { "Username" } label for="username" { "Username" }
input type="text" name="username" placeholder="Username" {} input type="text" name="username" placeholder="Username" {}
@ -24,6 +24,11 @@ impl Route<'_, ()> for LoginPrompt {
button type="submit" { "Login" } button type="submit" { "Login" }
} }
span {
a href="/auth/registerprompt" hx-get="/auth/registerprompt" hx-target="#modal-content" hx-swap="innerHTML" { "Register" }
a href="/auth/registerprompt" hx-get="/auth/resetpasswordprompt" hx-target="#modal-content" hx-swap="innerHTML" { "Reset Password" }
}
} }
} }
} }

View File

@ -1,20 +1,19 @@
use axum::extract::{Form, State};
use axum::http::{HeaderValue, Response, StatusCode};
use axum::response::{IntoResponse, Redirect};
use serde::Deserialize;
use tower_sessions::Session;
use crate::web::service::AppState;
use crate::web::service::routes::Route; use crate::web::service::routes::Route;
use crate::web::service::AppState;
use axum::extract::State;
use axum::http::{HeaderValue, StatusCode};
use axum::response::IntoResponse;
use tower_sessions::Session;
pub(crate) struct Logout; pub(crate) struct Logout;
impl Route<'_, ()> for Logout { impl Route<'_, (), ()> for Logout {
const PATH: &'static str = "/logout"; const PATH: &'static str = "/logout";
async fn process(session: Session, state: State<AppState>, _: ()) -> impl IntoResponse { async fn process(_: (), session: Session, _state: State<AppState>, _: ()) -> impl IntoResponse {
session.flush().await.expect("failed to flush session"); session.flush().await.expect("failed to flush session");
let mut response = StatusCode::OK.into_response(); let mut response = StatusCode::OK.into_response();
response.headers_mut().insert("HX-Redirect", HeaderValue::from_str("/").unwrap()); response.headers_mut().insert("HX-Redirect", HeaderValue::from_static("/"));
response response
} }
} }

View File

@ -7,13 +7,13 @@ use crate::web::service::routes::Route;
pub(crate) struct LogoutPrompt; pub(crate) struct LogoutPrompt;
impl Route<'_, ()> for LogoutPrompt { impl Route<'_, (), ()> for LogoutPrompt {
const PATH: &'static str = "/logoutprompt"; const PATH: &'static str = "/logoutprompt";
async fn process(_session: Session, _state: State<AppState>, _query: ()) -> impl IntoResponse { async fn process(_: (), _session: Session, _state: State<AppState>, _query: ()) -> impl IntoResponse {
html! { html! {
h1 { "Logout" } h1 { "Logout" }
form hx-post="/auth/logout" hx-target="#modal-content" hx-swap="innerHTML" { form method="post" action="/auth/logout" hx-post="/auth/logout" hx-target="#modal-content" hx-swap="innerHTML" {
p { "Are you sure you want to logout?" } p { "Are you sure you want to logout?" }
button type="submit" { "Logout" } button type="submit" { "Logout" }
} }

View File

@ -1,10 +1,14 @@
pub mod loginprompt; mod loginprompt;
pub mod registerprompt; mod registerprompt;
pub mod logoutprompt; mod logoutprompt;
pub mod login; mod login;
pub mod register; mod register;
pub mod logout; mod logout;
mod confirm; mod confirm;
mod resetpasswordprompt;
mod requestresetpassword;
mod resetpassword;
mod resetpassworddo;
pub(crate) const PATH: &'static str = "/auth"; pub(crate) const PATH: &'static str = "/auth";
@ -14,4 +18,8 @@ pub(crate) use logoutprompt::LogoutPrompt;
pub(crate) use login::Login; pub(crate) use login::Login;
pub(crate) use register::Register; pub(crate) use register::Register;
pub(crate) use logout::Logout; pub(crate) use logout::Logout;
pub(crate) use confirm::Confirm; pub(crate) use confirm::Confirm;
pub(crate) use resetpasswordprompt::ResetPasswordPrompt;
pub(crate) use requestresetpassword::RequestResetPassword;
pub(crate) use resetpassword::ResetPassword;
pub(crate) use resetpassworddo::DoResetPassword;

View File

@ -6,7 +6,7 @@ use tower_sessions::Session;
use crate::web::content::partials::page::show_error; use crate::web::content::partials::page::show_error;
use crate::web::service::AppState; use crate::web::service::AppState;
use crate::web::service::routes::Route; use crate::web::service::routes::Route;
use crate::web::service::auth::hash::hash_password_async; use crate::web::service::auth::hash::{ensure_password_strength, hash_password_async};
use crate::web::service::auth::user::{RegistrationError, User}; use crate::web::service::auth::user::{RegistrationError, User};
#[derive(Deserialize, Default, Debug)] #[derive(Deserialize, Default, Debug)]
@ -17,15 +17,16 @@ pub(crate) struct Register {
password_confirmation: String, password_confirmation: String,
} }
impl Route<'_, Form<Register>> for Register { impl Route<'_, (), Form<Register>> for Register {
const PATH: &'static str = "/register"; const PATH: &'static str = "/register";
async fn process(_session: Session, State(state): State<AppState>, Form(form): Form<Register>) -> impl IntoResponse { async fn process(_: (), _session: Session, State(state): State<AppState>, Form(form): Form<Register>) -> impl IntoResponse {
if form.password != form.password_confirmation { if form.password != form.password_confirmation {
return show_error("Passwords do not match").into_response(); return show_error("Passwords do not match").into_response();
} }
if form.password.len() < 10 || form.password.len() > 128 {
return show_error("Password must be 10 to 128 characters").into_response(); if let Err(e) = ensure_password_strength(&form.password) {
return show_error(&e).into_response();
} }
let password_hash = match hash_password_async(form.password, state.config.pepper.clone()).await { let password_hash = match hash_password_async(form.password, state.config.pepper.clone()).await {

View File

@ -7,13 +7,13 @@ use crate::web::service::routes::Route;
pub(crate) struct RegisterPrompt; pub(crate) struct RegisterPrompt;
impl Route<'_, ()> for RegisterPrompt { impl Route<'_, (), ()> for RegisterPrompt {
const PATH: &'static str = "/registerprompt"; const PATH: &'static str = "/registerprompt";
async fn process(_session: Session, _state: State<AppState>, _query: ()) -> impl IntoResponse { async fn process(_: (), _session: Session, _state: State<AppState>, _query: ()) -> impl IntoResponse {
html! { html! {
h1 { "Create an account" } h1 { "Create an account" }
form hx-post="/auth/register" hx-target="#modal-content" hx-swap="innerHTML" { form method="post" action="/auth/register" hx-post="/auth/register" hx-target="#modal-content" hx-swap="innerHTML" {
label for="username" { "Username" } label for="username" { "Username" }
input type="text" name="username" placeholder="Username" {} input type="text" name="username" placeholder="Username" {}
@ -32,6 +32,11 @@ impl Route<'_, ()> for RegisterPrompt {
button type="submit" { "Register" } button type="submit" { "Register" }
} }
span {
a href="/auth/loginprompt" hx-get="/auth/loginprompt" hx-target="#modal-content" hx-swap="innerHTML" { " Login " }
a href="/auth/resetpasswordprompt" hx-get="/auth/resetpasswordprompt" hx-target="#modal-content" hx-swap="innerHTML" { "Reset Password" }
}
} }
} }
} }

View File

@ -0,0 +1,54 @@
use axum::extract::State;
use axum::Form;
use axum::response::IntoResponse;
use serde::Deserialize;
use time::{Duration, OffsetDateTime};
use tower_sessions::Session;
use crate::web::content::partials::page::show_error;
use crate::web::service::AppState;
use crate::web::service::auth::confirmation::Confirmation;
use crate::web::service::auth::email::EmailHandler;
use crate::web::service::auth::user::User;
use crate::web::service::routes::Route;
#[derive(Deserialize, Default)]
pub(crate) struct RequestResetPassword {
email: String,
}
impl Route<'_, (), Form<RequestResetPassword>> for RequestResetPassword {
const PATH: &'static str = "/requestresetpassword";
async fn process(_: (), session: Session, state: State<AppState>, Form(form): Form<RequestResetPassword>) -> impl IntoResponse {
let user: Option<User> = User::get_user_by_email(&form.email, &state.db).await.unwrap_or(None);
let Some(mut user) = user else {
return show_error("If this account exists, a password reset email will be sent").into_response();
};
if let Some(last_reset) = user.password_reset_requested_at && OffsetDateTime::now_utc() - last_reset < time::Duration::minutes(5) {
return show_error("Please wait 5 minutes before requesting a new password reset").into_response();
}
if user.set_password_reset_requested(&state.db).await.is_err() {
return show_error("Failed to request password reset").into_response();
}
let email_handler = EmailHandler{};
let confirmation = Confirmation::new(user.id, "password_reset".into(), Duration::hours(1));
match confirmation.get_url_token() {
Ok(token) => {
println!("http://localhost:3000/auth/resetpassword?token={}", urlencoding::encode(&token));
email_handler.send_password_reset_email(form.email, token);
if confirmation.submit(&state.db).await.is_err() {
return show_error("Failed to send password reset").into_response();
};
}
Err(_) => {
return show_error("Failed to send password reset").into_response();
}
}
show_error("If this account exists, a password reset email will be sent").into_response()
}
}

View File

@ -0,0 +1,113 @@
use axum::extract::{Query, State};
use axum::response::IntoResponse;
use maud::html;
use serde::Deserialize;
use sha2::Digest;
use subtle::ConstantTimeEq;
use tower_sessions::Session;
use crate::web::content::partials::page::page;
use crate::web::content::style::CssFragment;
use crate::web::service::AppState;
use crate::web::service::auth::confirmation::Confirmation;
use crate::web::service::auth::user::User;
use crate::web::service::routes::Route;
#[derive(Deserialize, Default)]
pub(crate) struct ResetPassword {
token: String
}
inventory::submit! {
CssFragment(
r#"
.password-reset {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-grow: 1;
> form {
display: flex;
flex-direction: column;
gap: 1rem;
}
}
"#
)
}
impl Route<'_, (), Query<ResetPassword>> for ResetPassword {
const PATH: &'static str = "/resetpassword";
async fn process(_: (), session: Session, state: State<AppState>, query: Query<ResetPassword>) -> impl IntoResponse {
let (identifier, verifier) = Confirmation::decode_url_token(&query.token).unwrap();
let verifier_hash = sha2::Sha256::digest(&verifier).0;
let confirmation: Option<Confirmation> =
Confirmation::get_by_identifier(&identifier, &state.db).await.unwrap_or(None);
let content: maud::PreEscaped<String> = match confirmation {
Some(confirmation) => {
if confirmation.action_type != "password_reset" {
html! {
h1 {
"Invalid confirmation type"
}
}
} else if confirmation.is_expired() {
html! {
h1 {
"Password Reset Expired"
}
}
} else {
match verifier_hash.ct_eq(&confirmation.verifier_hash).unwrap_u8() {
1 => {
let user: Option<User> = User::get_user_by_id(confirmation.user_id, &state.db).await.unwrap_or(None);
match user {
Some(user) => {
html! {
div class="password-reset" {
form method="post" action="/auth/doresetpassword" hx-post="/auth/doresetpassword" hx-target="#modal-content" hx-swap="innerHTML" {
input type="hidden" name="token" value=(query.token);
label for="password" { "Password" }
input type="password" name="password" required;
label for="password_confirmation" { "Confirm Password" }
input type="password" name="password_confirmation" required;
button type="submit" { "Reset Password" }
}
}
}
},
None => {
html! {
h1 {
"User Not Found"
}
}
}
}
}
_ => {
html! {
h1 {
"Invalid Token"
}
}
}
}
}
}
None => {
html! {
h1 {
"Password Reset Not Found"
}
}
}
};
page(Self::NAME, Self::PATH, !session.is_empty().await, content)
}
}

View File

@ -0,0 +1,82 @@
use axum::extract::State;
use axum::Form;
use axum::response::IntoResponse;
use serde::Deserialize;
use sha2::Digest;
use subtle::ConstantTimeEq;
use tower_sessions::Session;
use crate::web::content::partials::page::show_error;
use crate::web::service::AppState;
use crate::web::service::auth::confirmation::Confirmation;
use crate::web::service::auth::hash::{ensure_password_strength, hash_password_async};
use crate::web::service::auth::user::User;
use crate::web::service::routes::Route;
#[derive(Deserialize, Default)]
pub struct DoResetPassword {
token: String,
password: String,
password_confirmation: String,
}
impl Route<'_, (), Form<DoResetPassword>> for DoResetPassword {
const PATH: &'static str = "/doresetpassword";
async fn process(_: (), _: Session, state: State<AppState>, Form(form): Form<DoResetPassword>) -> impl IntoResponse {
if form.password != form.password_confirmation {
return show_error("Passwords do not match").into_response();
}
if ensure_password_strength(&form.password).is_err() {
return show_error("Password is not strong enough").into_response();
}
let (identifier, verifier) = Confirmation::decode_url_token(&form.token).unwrap();
let verifier_hash = sha2::Sha256::digest(&verifier).0;
let confirmation: Option<Confirmation> =
Confirmation::get_by_identifier(&identifier, &state.db).await.unwrap_or(None);
if confirmation.is_none() {
return show_error("Invalid confirmation").into_response();
}
let confirmation = confirmation.unwrap();
if confirmation.action_type != "password_reset" {
return show_error("Invalid confirmation type").into_response();
}
if confirmation.is_expired() {
return show_error("Password reset expired").into_response();
}
if verifier_hash.ct_eq(&confirmation.verifier_hash).unwrap_u8() != 1 {
return show_error("Invalid confirmation").into_response();
}
let user = User::get_user_by_id(confirmation.user_id, &state.db).await;
if user.is_err() {
return show_error("User not found").into_response();
}
let user = user.unwrap();
if user.is_none() {
return show_error("User not found").into_response();
}
let mut user = user.unwrap();
let password_hash = match hash_password_async(form.password, state.config.pepper.clone()).await {
Ok(hash) => hash,
Err(_) => return show_error("Password reset failed").into_response(),
};
if Confirmation::delete_all_for_user_with_type(user.id, "password_reset", &state.db).await.is_err() {
return show_error("Password reset failed").into_response();
}
if user.update_password(&password_hash, &state.db).await.is_err() {
return show_error("Password reset failed").into_response();
}
return show_error("Password reset successful").into_response();
}
}

View File

@ -0,0 +1,29 @@
use axum::extract::State;
use axum::response::IntoResponse;
use maud::html;
use tower_sessions::Session;
use crate::web::service::AppState;
use crate::web::service::routes::Route;
pub(crate) struct ResetPasswordPrompt;
impl Route<'_, (), ()> for ResetPasswordPrompt {
const PATH: &'static str = "/resetpasswordprompt";
async fn process(_ad1: (), _session: Session, _state: State<AppState>, _query: ()) -> impl IntoResponse {
html! {
h1 { "Reset Password" }
form method="post" action="/auth/requestresetpassword" hx-post="/auth/requestresetpassword" hx-target="#modal-content" hx-swap="innerHTML" {
label for="email" { "Email" }
input type="email" name="email" id="email" required;
button type="submit" { "Request Reset" }
}
span {
a href="/auth/loginprompt" hx-get="/auth/loginprompt" hx-target="#modal-content" hx-swap="innerHTML" { "Login" }
a href="/auth/registerprompt" hx-get="/auth/registerprompt" hx-target="#modal-content" hx-swap="innerHTML" { "Register" }
}
}
}
}

View File

@ -1,15 +1,16 @@
mod root; mod root;
mod auth; mod auth;
mod profile;
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
use axum::extract::{Request, State}; use axum::extract::{FromRequestParts, Request, State};
use axum::http::{header, StatusCode}; use axum::http::{header, StatusCode};
use axum::middleware::Next; use axum::middleware::Next;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::{middleware, Router}; use axum::{middleware, Router};
use axum::error_handling::HandleErrorLayer; use axum::error_handling::HandleErrorLayer;
use axum::routing::{get, post}; use axum::routing::get;
use sqlx::{PgPool}; use sqlx::{PgPool};
use tower_csrf::{CrossOriginProtectionLayer, ProtectionError}; use tower_csrf::{CrossOriginProtectionLayer, ProtectionError};
use tower_governor::governor::GovernorConfig; use tower_governor::governor::GovernorConfig;
@ -24,11 +25,11 @@ use crate::web::service::{
}; };
use tower_governor::GovernorLayer; use tower_governor::GovernorLayer;
pub(crate) trait Route<'a, AD> { pub(crate) trait Route<'a, AS: Sized + FromRequestParts<AppState>, AD: Sized> {
const PATH: &'static str; const PATH: &'static str;
const NAME: &'static str = "MicroTLD"; const NAME: &'static str = "MicroTLD";
async fn process(session: Session, state: State<AppState>, query: AD) -> impl IntoResponse { async fn process(_ad1: AS, _session: Session, _state: State<AppState>, _query: AD) -> impl IntoResponse {
"You should fill out get_content for this route." "You should fill out get_content for this route."
} }
} }
@ -51,25 +52,47 @@ async fn my_custom_middleware(
response response
} }
macro_rules! routes {
( $router:expr, $( $method:ident ( $type:path ) ),* $(,)? ) => {
$router
$(
.route(<$type>::PATH, axum::routing::$method(<$type>::process))
)*
};
}
fn auth_routes() -> Router<AppState> { fn auth_routes() -> Router<AppState> {
let governor_config = GovernorConfig::default(); // 1 request per 500ms per IP let governor_config = GovernorConfig::default(); // 1 request per 500ms per IP
let governor_layer = GovernorLayer::new(Arc::new(governor_config)); let governor_layer = GovernorLayer::new(Arc::new(governor_config));
Router::new() routes!(
.layer(governor_layer) Router::new()
.route(auth::LoginPrompt::PATH, get(auth::LoginPrompt::process)) .layer(governor_layer),
.route(auth::LogoutPrompt::PATH, get(auth::LogoutPrompt::process)) get(auth::LoginPrompt),
.route(auth::RegisterPrompt::PATH, get(auth::RegisterPrompt::process)) get(auth::LogoutPrompt),
.route(auth::Login::PATH, post(auth::Login::process)) get(auth::RegisterPrompt),
.route(auth::Logout::PATH, post(auth::Logout::process)) get(auth::ResetPasswordPrompt),
.route(auth::Register::PATH, post(auth::Register::process)) get(auth::ResetPassword),
.route(auth::Confirm::PATH, get(auth::Confirm::process)) post(auth::RequestResetPassword),
post(auth::DoResetPassword),
post(auth::Login),
post(auth::Logout),
post(auth::Register),
get(auth::Confirm)
)
} }
pub async fn apply_routes(router: axum::Router<AppState>, pool: PgPool) -> Router<AppState> { fn profile_routes() -> Router<AppState> {
routes!(
Router::new(),
get(profile::Profile),
post(profile::UnlockSettings),
post(profile::UpdateSettings)
)
}
pub async fn apply_routes(router: Router<AppState>, pool: PgPool) -> Router<AppState> {
let css = style::stylesheet(); let css = style::stylesheet();
let csrf_layer = tower::ServiceBuilder::new() let csrf_layer = tower::ServiceBuilder::new()
@ -84,10 +107,13 @@ pub async fn apply_routes(router: axum::Router<AppState>, pool: PgPool) -> Route
)) ))
.layer(CrossOriginProtectionLayer::default()); .layer(CrossOriginProtectionLayer::default());
router routes!(
.route(root::Root::PATH, get(root::Root::process)) router
.route(&css.route, get(css_handler))
.nest(auth::PATH, auth_routes()) .nest(auth::PATH, auth_routes())
.nest(profile::PATH, profile_routes()),
get(root::Root),
)
.route(&css.route, get(css_handler))
.route("/assets/{*path}", get(static_handler)) .route("/assets/{*path}", get(static_handler))
.layer(csrf_layer) .layer(csrf_layer)
.layer(session_layer(pool).await) .layer(session_layer(pool).await)

View File

@ -0,0 +1,9 @@
mod unlock;
mod profile;
mod update;
pub(crate) const PATH: &'static str = "/profile";
pub(crate) use profile::Profile;
pub(crate) use unlock::UnlockSettings;
pub(crate) use update::UpdateSettings;

View File

@ -0,0 +1,112 @@
use axum::extract::State;
use axum::response::IntoResponse;
use maud::html;
use time::OffsetDateTime;
use tower_sessions::Session;
use crate::web::content::partials::page::page;
use crate::web::content::partials::profilesettings::profilesettings;
use crate::web::content::style::CssFragment;
use crate::web::service::AppState;
use crate::web::service::extractors::authuser::AuthUser;
use crate::web::service::routes::Route;
inventory::submit! {
CssFragment(r#"
.profile {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-grow: 1;
}
.profile .height-100 {
height: 100%;
}
.profile .grid-3 {
display: grid;
grid-template-columns: 1fr minmax(1px, 5rem) 1fr;
grid-template-rows: 1fr;
}
.profile .flex-h {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.profile .spacer-5 {
max-width: 5rem;
}
.profile vr {
display: inline-block;
width: 0;
height: 100%;
border-left: 1px solid #fff;
}
.profile .flex-v {
display: flex;
flex-direction: column;
align-items: center;
justify-content: end;
}
.profile form {
display: flex;
flex-direction: column;
}
.profile form.gridform-h {
display: grid;
grid-template-columns: 1fr 2fr auto;
}
"#)
}
pub(crate) struct Profile;
impl Route<'_, AuthUser, ()> for Profile {
const PATH: &'static str = "/";
async fn process(AuthUser(user): AuthUser, session: Session, State(_app_state): State<AppState>, _query: ()) -> impl IntoResponse {
page(Self::NAME, Self::PATH, !session.is_empty().await, html! {
div class="profile" {
h1 { "Profile" }
div class="grid-3" {
div class="flex-v height-100" {
form method="post" action="/profile/unlock-settings" hx-post="/profile/unlock-settings" hx-target="#profilesettings" hx-swap="innerHTML" {
table {
tr {
td { label for="lock" { "Lock settings" } }
td {
@if user.is_settings_unlocked() {
input type="checkbox" name="lock" value="true" checked;
} @else {
input type="checkbox" name="lock" value="true";
}
}
}
tr {
td { label for="password" { "Current Password" } }
td {input type="password" name="password" value="";}
}
}
input type="submit" value="Set Lock";
}
}
div class="spacer-5 flex-h height-100" {
vr {}
}
div id="profilesettings" class="flex-v height-100" {
(profilesettings(!user.is_settings_unlocked(), None, &user.username, &user.email))
}
}
}
})
}
}

View File

@ -0,0 +1,60 @@
use axum::extract::State;
use axum::Form;
use axum::response::IntoResponse;
use maud::html;
use serde::Deserialize;
use tower_sessions::Session;
use time::OffsetDateTime;
use crate::web::content::partials;
use crate::web::service::AppState;
use crate::web::service::auth::hash::verify_password_async;
use crate::web::service::extractors::authuser::AuthUser;
use crate::web::service::routes::Route;
#[derive(Deserialize, Default)]
pub(crate) struct UnlockSettings {
#[serde(default)]
pub lock: bool,
pub password: String,
}
impl Route<'_, AuthUser, Form<UnlockSettings>> for UnlockSettings {
const PATH: &'static str = "/unlock-settings";
async fn process(AuthUser(mut user): AuthUser, session: Session, state: State<AppState>, Form(form): Form<UnlockSettings>) -> impl IntoResponse {
if form.lock {
match user.set_settings_unlocked(false, &state.db).await {
Ok(r) =>
if r.rows_affected() == 0 {
return html! {
h1 { "DB Error" }
p { "Could not lock settings: " }
};
},
Err(_) => return html! {
h1 { "DB Error" }
p { "Could not lock settings: " }
},
}
} else {
if verify_password_async(form.password, user.password_hash.clone(), state.config.pepper.clone())
.await
.is_err() {
return partials::profilesettings::profilesettings(!user.is_settings_unlocked(), Some("Incorrect password"), &user.username, &user.email);
}
match user.set_settings_unlocked(true, &state.db).await {
Ok(r) =>
if r.rows_affected() == 0 {
return html! {
h1 { "DB Error" }
p { "Could not unlock settings: " }
};
},
Err(_) => return html! {
h1 { "DB Error" }
p { "Could not unlock settings: " }
},
}
}
partials::profilesettings::profilesettings(!user.is_settings_unlocked(), None, &user.username, &user.email)
}
}

View File

@ -0,0 +1,69 @@
use std::os::linux::raw::stat;
use axum::extract::State;
use axum::Form;
use axum::response::IntoResponse;
use serde::Deserialize;
use tower_sessions::Session;
use crate::web::content::partials::profilesettings::profilesettings;
use crate::web::service::AppState;
use crate::web::service::auth::hash::{ensure_password_strength, hash_password_async};
use crate::web::service::extractors::authuser::AuthUser;
use crate::web::service::routes::Route;
#[derive(Deserialize, Default)]
pub(crate) struct UpdateSettings {
username: Option<String>,
email: Option<String>,
password: Option<String>,
}
impl Route<'_, AuthUser, Form<UpdateSettings>> for UpdateSettings {
const PATH: &'static str = "/update-settings";
async fn process(AuthUser(mut user): AuthUser, session: Session, state: State<AppState>, Form(form): Form<UpdateSettings>) -> impl IntoResponse {
let mut message: Option<String> = None;
'update_block: {
if let Some(username) = form.username {
if let Err(e) = user.update_username(&username, &state.db).await {
message = Some(e.to_string());
break 'update_block;
}
message = Some("Updated Username".to_string());
} else if let Some(email) = form.email {
if let Err(e) = user.update_email(&email, &state.db).await {
message = Some(e.to_string());
break 'update_block;
}
message = Some("Updated Email".to_string());
} else if let Some(password) = form.password {
if let Err(e) = ensure_password_strength(&password) {
message = Some(e.to_string());
break 'update_block;
}
let password_hash = match hash_password_async(password, state.config.pepper.clone()).await {
Ok(hash) => hash,
Err(e) => {
message = Some(e.to_string());
break 'update_block;
}
};
if let Err(e) = user.update_password(&password_hash, &state.db).await {
message = Some(e.to_string());
break 'update_block;
}
user.password_hash = password_hash;
if let Err(e) = user.set_settings_unlocked(false, &state.db).await {
message = Some(e.to_string());
break 'update_block;
}
message = Some("Updated Password".to_string());
}
}
if let Some(s) = message {
profilesettings(!user.is_settings_unlocked(), Some(&s), &user.username, &user.email)
} else {
profilesettings(!user.is_settings_unlocked(), None, &user.username, &user.email)
}
}
}

View File

@ -3,17 +3,32 @@ use axum::response::IntoResponse;
use maud::html; use maud::html;
use tower_sessions::Session; use tower_sessions::Session;
use crate::web::content::partials::page::page; use crate::web::content::partials::page::page;
use crate::web::content::style::CssFragment;
use crate::web::service::AppState; use crate::web::service::AppState;
use crate::web::service::routes::Route; use crate::web::service::routes::Route;
pub(crate) struct Root; pub(crate) struct Root;
impl Route<'_, ()> for Root { inventory::submit! {
CssFragment(r#"
#home {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-grow: 1;
}"#
)
}
impl Route<'_, (), ()> for Root {
const PATH: &'static str = "/"; const PATH: &'static str = "/";
async fn process(session: Session, State(_app_state): State<AppState>, _query: ()) -> impl IntoResponse { async fn process(_: (), session: Session, State(_app_state): State<AppState>, _query: ()) -> impl IntoResponse {
page(Self::NAME, Self::PATH, !session.is_empty().await, html! { page(Self::NAME, Self::PATH, !session.is_empty().await, html! {
"Hello, world!" div id="home" {
h1 { "Welcome to MicroTLD" }
}
}) })
} }
} }