This commit is contained in:
sam 2023-10-22 17:14:53 +02:00
parent 2843aec125
commit dea8968f6b
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
11 changed files with 276 additions and 39 deletions

43
Cargo.lock generated
View file

@ -225,6 +225,19 @@ version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
[[package]]
name = "bcrypt"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d1c9c15093eb224f0baa400f38fcd713fc1391a6f1c389d886beef146d60a3"
dependencies = [
"base64",
"blowfish",
"getrandom",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"
@ -249,6 +262,16 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "blowfish"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
dependencies = [
"byteorder",
"cipher",
]
[[package]] [[package]]
name = "bstr" name = "bstr"
version = "1.7.0" version = "1.7.0"
@ -307,6 +330,16 @@ dependencies = [
"windows-targets", "windows-targets",
] ]
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]] [[package]]
name = "clap" name = "clap"
version = "4.4.6" version = "4.4.6"
@ -857,6 +890,7 @@ name = "imgboard"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"axum", "axum",
"bcrypt",
"chrono", "chrono",
"clap", "clap",
"dotenvy", "dotenvy",
@ -888,6 +922,15 @@ dependencies = [
"hashbrown", "hashbrown",
] ]
[[package]]
name = "inout"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.11.0" version = "0.11.0"

View file

@ -6,7 +6,8 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
axum = { version = "0.6.20", features = ["tracing", "macros", "headers"] } axum = { version = "0.6.20", features = ["tracing", "macros", "headers", "form"] }
bcrypt = "0.15.0"
chrono = { version = "0.4.31", features = ["serde"] } chrono = { version = "0.4.31", features = ["serde"] }
clap = { version = "4.4.6", features = ["derive", "env"] } clap = { version = "4.4.6", features = ["derive", "env"] }
dotenvy = "0.15.7" dotenvy = "0.15.7"

View file

@ -20,9 +20,6 @@ use crate::config::Config;
use crate::state::AppState; use crate::state::AppState;
use crate::templates::handlebars; use crate::templates::handlebars;
use crate::pages::index::index;
use crate::pages::user::{get_user, get_user_new};
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
dotenvy::dotenv().ok(); dotenvy::dotenv().ok();
@ -52,9 +49,9 @@ async fn main() -> Result<()> {
debug!("Building router"); debug!("Building router");
let app = Router::new() let app = Router::new()
.route("/", get(index)) .route("/", get(pages::index))
.route("/users/:id", get(get_user)) .route("/users/:id", get(pages::get_user))
.route("/users/new", get(get_user_new)) .route("/users/new", get(pages::get_user_new).post(pages::post_user_new))
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.with_state(state); .with_state(state);

View file

@ -1,10 +1,11 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Serialize, Serializer}; use serde::{Serialize, Serializer};
#[derive(Debug, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct User { pub struct User {
pub id: i32, pub id: i32,
pub username: String, pub username: String,
#[serde(skip_serializing)]
pub password: String, pub password: String,
pub role: Role, pub role: Role,
@ -12,7 +13,7 @@ pub struct User {
pub last_active: DateTime<Utc>, pub last_active: DateTime<Utc>,
} }
#[derive(sqlx::Type, Debug)] #[derive(sqlx::Type, Debug, Clone, PartialEq, Eq)]
#[repr(i32)] #[repr(i32)]
pub enum Role { pub enum Role {
Viewer = 1, Viewer = 1,
@ -37,12 +38,27 @@ impl Serialize for Role {
impl From<i32> for Role { impl From<i32> for Role {
fn from(value: i32) -> Self { fn from(value: i32) -> Self {
// From<i32> is only used for database mapping, so panicking on invalid values is okay
match value { match value {
1 => Self::Viewer, 1 => Self::Viewer,
2 => Self::Editor, 2 => Self::Editor,
3 => Self::Manager, 3 => Self::Manager,
4 => Self::Admin, 4 => Self::Admin,
_ => unreachable!("Role should never be outside 1-4") _ => unreachable!("Role should never be outside 1-4"),
}
}
}
impl TryFrom<&str> for Role {
type Error = eyre::Error;
fn try_from(value: &str) -> Result<Self, Self::Error> {
match value {
"viewer" => Ok(Self::Viewer),
"editor" => Ok(Self::Editor),
"manager" => Ok(Self::Manager),
"admin" => Ok(Self::Admin),
_ => Err(eyre::eyre!("Invalid role string value")),
} }
} }
} }

View file

@ -1,2 +1,5 @@
pub mod index; mod index;
pub mod user; mod user;
pub use index::index;
pub use user::{get_user, get_user_new, post_user_new};

View file

@ -1,11 +1,14 @@
use std::{collections::BTreeMap, sync::Arc}; use std::{collections::BTreeMap, sync::Arc};
use axum::{ use axum::{
body::BoxBody,
extract::{Path, State}, extract::{Path, State},
response::IntoResponse, response::{IntoResponse, Response},
Form,
}; };
use serde::Deserialize;
use sqlx::{query, query_as}; use sqlx::{query, query_as};
use tracing::error; use tracing::{error, info};
use crate::{ use crate::{
extractor::ExtractUserToken, extractor::ExtractUserToken,
@ -25,47 +28,55 @@ pub async fn get_user(
Ok(user) => user, Ok(user) => user,
Err(why) => { Err(why) => {
error!("Getting user: {}", why); error!("Getting user: {}", why);
return Page::new( return Page::new(state.hbs.render(
state "error.hbs",
.hbs &PageData {
.render::<PageData>("error.hbs", &Default::default()), message: Some("Internal server error".into()),
); ..Default::default()
},
));
} }
}; };
Page::new( Page::new(state.hbs.render(
state "error.hbs",
.hbs &PageData {
.render("error.hbs", &PageData { user: Some(user) }), user: Some(user),
) ..Default::default()
},
))
} }
pub async fn get_user_new( pub async fn get_user_new(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
ExtractUserToken(user): ExtractUserToken, ExtractUserToken(user): ExtractUserToken,
) -> impl IntoResponse { ) -> impl IntoResponse {
if user.is_some() // If there's already an authenticated user, check if they're an admin
&& match user.as_ref().unwrap().role { // Admins can create new users, others can't
Role::Viewer => false, if let Some(user) = user {
Role::Editor => false, tracing::debug!("User is authenticated");
Role::Manager => false,
Role::Admin => true, if user.role == Role::Admin {
tracing::debug!("Authenticated user is admin");
return Page::new(
state
.hbs
.render::<PageData>("new_user.hbs", &Default::default()),
);
} }
{
// Admins can create accounts tracing::debug!("Authenticated user is not an admin");
// Handlebars expects *some* value when rendering a template, so just pass an empty BTreeMap
let map = BTreeMap::<&str, &str>::new();
return Page::new(state.hbs.render("new_user.hbs", &map));
} else if user.is_some() {
// No permissions to create account
return Page::new( return Page::new(
state state
.hbs .hbs
.render::<PageData>("error.hbs", &Default::default()), .render::<PageData>("error.hbs", &Default::default()),
); );
} }
// Else, let the user create a new account if no accounts exist yet
tracing::debug!("There is no authenticated user");
// Else, let the user create a new account, but only if no accounts exist yet
let count = match query!(r#"SELECT COUNT(id) FROM users"#) let count = match query!(r#"SELECT COUNT(id) FROM users"#)
.fetch_one(&state.pool) .fetch_one(&state.pool)
.await .await
@ -81,8 +92,145 @@ pub async fn get_user_new(
} }
}; };
tracing::debug!("Current user count: {}", count);
let mut map = BTreeMap::new(); let mut map = BTreeMap::new();
map.insert("count", count); map.insert("count", count);
Page::new(state.hbs.render("new_user.hbs", &map)) Page::new(state.hbs.render("new_user.hbs", &map))
} }
#[derive(Deserialize)]
pub struct UserNew {
pub username: String,
pub password: String,
pub password2: String,
}
pub async fn post_user_new(
State(state): State<Arc<AppState>>,
ExtractUserToken(existing_user): ExtractUserToken,
Form(form_data): Form<UserNew>,
) -> Response<BoxBody> {
if existing_user.is_none() {
let count = match query!(r#"SELECT COUNT(id) FROM users"#)
.fetch_one(&state.pool)
.await
{
Ok(r) => r.count.unwrap_or(0),
Err(why) => {
error!("Getting user count: {}", why);
return Page::new(state.hbs.render(
"error.hbs",
&PageData {
message: Some("Internal server error".into()),
..Default::default()
},
))
.into_response();
}
};
if count != 0 {
info!("");
return Page::new(state.hbs.render(
"error.hbs",
&PageData {
message: Some("You need to be logged in to create new users".into()),
..Default::default()
},
))
.into_response();
}
} else if let Some(user) = existing_user.clone() {
if user.role != Role::Admin {
info!(
user_id = user.id,
"User tried to access POST /users/new despite not being admin"
);
return Page::new(state.hbs.render(
"error.hbs",
&PageData {
message: Some("Only admins can create new users".into()),
..Default::default()
},
))
.into_response();
}
}
if form_data.password != form_data.password2 {
return Page::new(state.hbs.render(
"new_user.hbs",
&PageData {
message: Some("Passwords don't match".into()),
..Default::default()
},
))
.into_response();
}
let hashed_password = match bcrypt::hash(form_data.password, 12) {
Ok(hash) => hash,
Err(why) => {
error!("Hashing password: {}", why);
return Page::new(state.hbs.render(
"error.hbs",
&PageData {
message: Some("Internal server error".into()),
..Default::default()
},
))
.into_response();
}
};
let user = match query_as!(
User,
r#"INSERT INTO users (username, password) VALUES ($1, $2) RETURNING *"#,
form_data.username,
hashed_password,
)
.fetch_one(&state.pool)
.await
{
Ok(u) => u,
Err(why) => {
error!("Creating user: {}", why);
return Page::new(state.hbs.render(
"error.hbs",
&PageData {
message: Some("Internal server error".into()),
..Default::default()
},
))
.into_response();
}
};
if existing_user.is_some() {
// Render template with new user info (ID, username)
return Page::new(state.hbs.render(
"new_user_confirm.hbs",
&PageData {
user: existing_user,
..Default::default()
},
))
.into_response();
}
Page::new(state.hbs.render(
"new_user.hbs",
&PageData {
..Default::default()
},
))
.into_response()
}

View file

@ -1,3 +1,5 @@
use std::sync::Arc;
use axum::{extract::{FromRef, FromRequestParts}, async_trait, http::request::Parts}; use axum::{extract::{FromRef, FromRequestParts}, async_trait, http::request::Parts};
use sqlx::{postgres::Postgres, Pool}; use sqlx::{postgres::Postgres, Pool};
@ -19,3 +21,9 @@ where
Ok(Self::from_ref(state)) Ok(Self::from_ref(state))
} }
} }
impl FromRef<Arc<AppState>> for AppState {
fn from_ref(input: &Arc<AppState>) -> Self {
(**input).clone()
}
}

View file

@ -54,4 +54,7 @@ impl IntoResponse for Page {
#[derive(Debug, Default, Serialize)] #[derive(Debug, Default, Serialize)]
pub struct PageData { pub struct PageData {
pub user: Option<User>, pub user: Option<User>,
// An optional flash message. Not all templates will handle this.
pub message: Option<String>,
} }

View file

@ -1,5 +1,5 @@
{{#*inline "page"}} {{#*inline "page"}}
<h1>Error</h1> <h1>Error</h1>
<p>An internal error occurred. Sorry :&lpar;</p> <p>Error: {{message}}</p>
{{/inline}} {{/inline}}
{{> root.hbs}} {{> root.hbs}}

View file

@ -1,5 +1,10 @@
{{#*inline "page"}} {{#*inline "page"}}
<h1>New user</h1> <h1>New user</h1>
{{#if message}}
<div>
{{message}}
</div>
{{/if}}
<form method="POST"> <form method="POST">
<label>Username <input type="text" name="username" /></label> <label>Username <input type="text" name="username" /></label>
<label>Password <input type="password" name="password" /></label> <label>Password <input type="password" name="password" /></label>

View file

@ -0,0 +1,13 @@
{{#*inline "page"}}
<h1>New user created!</h1>
{{#if message}}
<div>
{{message}}
</div>
{{/if}}
<ul>
<li>ID: {{new_user.id}}</li>
<li>Username: {{new_user.username}}</li>
</ul>
{{/inline}}
{{> root.hbs}}