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"
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]]
name = "bitflags"
version = "1.3.2"
@ -249,6 +262,16 @@ dependencies = [
"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]]
name = "bstr"
version = "1.7.0"
@ -307,6 +330,16 @@ dependencies = [
"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]]
name = "clap"
version = "4.4.6"
@ -857,6 +890,7 @@ name = "imgboard"
version = "0.1.0"
dependencies = [
"axum",
"bcrypt",
"chrono",
"clap",
"dotenvy",
@ -888,6 +922,15 @@ dependencies = [
"hashbrown",
]
[[package]]
name = "inout"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5"
dependencies = [
"generic-array",
]
[[package]]
name = "itertools"
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
[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"] }
clap = { version = "4.4.6", features = ["derive", "env"] }
dotenvy = "0.15.7"

View file

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

View file

@ -1,10 +1,11 @@
use chrono::{DateTime, Utc};
use serde::{Serialize, Serializer};
#[derive(Debug, Serialize)]
#[derive(Debug, Clone, Serialize)]
pub struct User {
pub id: i32,
pub username: String,
#[serde(skip_serializing)]
pub password: String,
pub role: Role,
@ -12,7 +13,7 @@ pub struct User {
pub last_active: DateTime<Utc>,
}
#[derive(sqlx::Type, Debug)]
#[derive(sqlx::Type, Debug, Clone, PartialEq, Eq)]
#[repr(i32)]
pub enum Role {
Viewer = 1,
@ -37,12 +38,27 @@ impl Serialize for Role {
impl From<i32> for Role {
fn from(value: i32) -> Self {
// From<i32> is only used for database mapping, so panicking on invalid values is okay
match value {
1 => Self::Viewer,
2 => Self::Editor,
3 => Self::Manager,
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;
pub mod user;
mod index;
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 axum::{
body::BoxBody,
extract::{Path, State},
response::IntoResponse,
response::{IntoResponse, Response},
Form,
};
use serde::Deserialize;
use sqlx::{query, query_as};
use tracing::error;
use tracing::{error, info};
use crate::{
extractor::ExtractUserToken,
@ -25,47 +28,55 @@ pub async fn get_user(
Ok(user) => user,
Err(why) => {
error!("Getting user: {}", why);
return Page::new(
state
.hbs
.render::<PageData>("error.hbs", &Default::default()),
);
return Page::new(state.hbs.render(
"error.hbs",
&PageData {
message: Some("Internal server error".into()),
..Default::default()
},
));
}
};
Page::new(
state
.hbs
.render("error.hbs", &PageData { user: Some(user) }),
)
Page::new(state.hbs.render(
"error.hbs",
&PageData {
user: Some(user),
..Default::default()
},
))
}
pub async fn get_user_new(
State(state): State<Arc<AppState>>,
ExtractUserToken(user): ExtractUserToken,
) -> impl IntoResponse {
if user.is_some()
&& match user.as_ref().unwrap().role {
Role::Viewer => false,
Role::Editor => false,
Role::Manager => false,
Role::Admin => true,
// If there's already an authenticated user, check if they're an admin
// Admins can create new users, others can't
if let Some(user) = user {
tracing::debug!("User is authenticated");
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
// 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
tracing::debug!("Authenticated user is not an admin");
return Page::new(
state
.hbs
.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"#)
.fetch_one(&state.pool)
.await
@ -81,8 +92,145 @@ pub async fn get_user_new(
}
};
tracing::debug!("Current user count: {}", count);
let mut map = BTreeMap::new();
map.insert("count", count);
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 sqlx::{postgres::Postgres, Pool};
@ -19,3 +21,9 @@ where
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)]
pub struct PageData {
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"}}
<h1>Error</h1>
<p>An internal error occurred. Sorry :&lpar;</p>
<p>Error: {{message}}</p>
{{/inline}}
{{> root.hbs}}

View file

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