Bloody beans, there's finally a workig database

This commit is contained in:
Daniel Flanagan 2023-11-14 16:05:27 -06:00
parent 22761ee92e
commit 61e3a2cc31
Signed by: lytedev
GPG key ID: 5B2020A0F9921EF4
18 changed files with 1943 additions and 106 deletions

View file

@ -1,2 +1,3 @@
[env] [env]
RUST_BACKTRACE = "1" RUST_BACKTRACE = "1"
RUSTFLAGS = "--cfg uuid_unstable"

1723
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -28,7 +28,8 @@ thiserror = "1.0.50"
axum-macros = "0.3.8" axum-macros = "0.3.8"
color-eyre = "0.6.2" color-eyre = "0.6.2"
# irust # db
# bacon sea-orm = { version = "0.12.6", features = ["sqlx-sqlite", "macros", "runtime-tokio-rustls"] }
# sqlx (sea orm?) sea-orm-migration = { version = "0.12.6", features = ["sqlx-sqlite"] }
# poem-openapi? uuid = { version = "1.5.0", features = ["v7", "atomic", "fast-rng", "macro-diagnostics"] }
password-hash = "0.5.0"

View file

@ -53,12 +53,19 @@
rust-analyzer rust-analyzer
nodePackages_latest.vscode-langservers-extracted nodePackages_latest.vscode-langservers-extracted
# to install sea-orm-cli
pkg-config
openssl
hurl hurl
]; ];
RUST_SRC_PATH = rustPlatform.rustLibSrc; RUST_SRC_PATH = rustPlatform.rustLibSrc;
shellHook = '' shellHook = ''
export DATABASE_URL="sqlite://./data/lyrs.sqlitedb"; export MIGRATION_DIR="src/migrator"
export DATABASE_URL="sqlite://./data/lyrs.sqlitedb?mode=rwc";
export COOKIE_KEY="2z49_8yfKUkoTOo0cjzzjwufCfhKvfOIc1CGleuTXC5zRqY4U0Xhkd34ipREQN5iHRH62tt5O7y6U5mmFBH3MA" export COOKIE_KEY="2z49_8yfKUkoTOo0cjzzjwufCfhKvfOIc1CGleuTXC5zRqY4U0Xhkd34ipREQN5iHRH62tt5O7y6U5mmFBH3MA"
export RUST_BACKTRACE="1"
export RUSTFLAGS="--cfg uuid_unstable"
''; '';
}; };
}); });

View file

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

View file

@ -1 +0,0 @@

5
src/entities/mod.rs Normal file
View file

@ -0,0 +1,5 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.6
pub mod prelude;
pub mod user;

3
src/entities/prelude.rs Normal file
View file

@ -0,0 +1,3 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.6
pub use super::user::Entity as User;

22
src/entities/user.rs Normal file
View file

@ -0,0 +1,22 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.12.6
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "user")]
pub struct Model {
#[sea_orm(
primary_key,
auto_increment = false,
column_type = "Binary(BlobSize::Blob(None))"
)]
pub id: Vec<u8>,
pub name: Option<String>,
pub username: String,
pub password_digest: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}
impl ActiveModelBehavior for ActiveModel {}

View file

@ -1,13 +1,14 @@
use core::fmt;
use axum::{ use axum::{
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
use core::fmt;
use thiserror::Error; use thiserror::Error;
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum AppError { pub enum AppError {
Database(#[from] sea_orm::error::DbErr),
PasswordHash(#[from] password_hash::Error),
InvalidCsrf(#[from] axum_csrf::CsrfError), InvalidCsrf(#[from] axum_csrf::CsrfError),
Other(#[from] anyhow::Error), Other(#[from] anyhow::Error),
} }
@ -17,6 +18,8 @@ impl AppError {
match self { match self {
AppError::Other(_) => StatusCode::INTERNAL_SERVER_ERROR, AppError::Other(_) => StatusCode::INTERNAL_SERVER_ERROR,
AppError::InvalidCsrf(_) => StatusCode::BAD_REQUEST, AppError::InvalidCsrf(_) => StatusCode::BAD_REQUEST,
AppError::PasswordHash(_) => StatusCode::INTERNAL_SERVER_ERROR,
AppError::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
} }
} }
@ -24,6 +27,8 @@ impl AppError {
match self { match self {
AppError::Other(e) => format!("something went wrong: {}", e), AppError::Other(e) => format!("something went wrong: {}", e),
AppError::InvalidCsrf(e) => format!("unable to verify csrf: {}", e), AppError::InvalidCsrf(e) => format!("unable to verify csrf: {}", e),
AppError::PasswordHash(e) => format!("failed to hash password: {}", e),
AppError::Database(e) => format!("database error: {}", e),
} }
} }
} }

View file

@ -1,9 +1,11 @@
use tracing::{instrument, trace}; use tracing::{info, instrument};
use tracing_subscriber::{filter::LevelFilter, EnvFilter}; use tracing_subscriber::{filter::LevelFilter, EnvFilter};
#[instrument]
pub fn init() { pub fn init() {
color_eyre::install().expect("Failed to install color_eyre"); color_eyre::install().expect("Failed to install color_eyre");
setup_trace_logger(); setup_trace_logger();
info!("Instrumentation initialized.");
} }
#[instrument] #[instrument]
@ -13,6 +15,4 @@ pub fn setup_trace_logger() {
.parse_lossy("info,lyrs=trace"); .parse_lossy("info,lyrs=trace");
tracing_subscriber::fmt().with_env_filter(filter).init(); tracing_subscriber::fmt().with_env_filter(filter).init();
trace!("Starting...");
} }

View file

@ -4,11 +4,11 @@
// TODO: Implement authn // TODO: Implement authn
mod app; mod app;
mod database; mod entities;
mod error; mod error;
mod feather_icons; mod feather_icons;
mod instrumentation; mod instrumentation;
mod models; mod migrator;
mod partials; mod partials;
mod router; mod router;
mod server; mod server;

12
src/migrator.rs Normal file
View file

@ -0,0 +1,12 @@
use sea_orm_migration::prelude::*;
pub struct Migrator;
mod m20231114_143300_init;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![Box::new(m20231114_143300_init::Migration)]
}
}

View file

@ -0,0 +1,52 @@
use sea_orm_migration::prelude::*;
pub struct Migration;
impl MigrationName for Migration {
fn name(&self) -> &str {
"m20231114_143300_init.rs"
}
}
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(User::Table)
.col(
ColumnDef::new(User::Id)
.binary_len(16)
.not_null()
.primary_key(),
)
.col(ColumnDef::new(User::Name).string())
.col(
ColumnDef::new(User::Username)
.string()
.not_null()
.unique_key(),
)
.col(ColumnDef::new(User::PasswordDigest).text().not_null())
.to_owned(),
)
.await
}
// Define how to rollback this migration: Drop the Bakery table.
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(User::Table).to_owned())
.await
}
}
#[derive(Iden)]
pub enum User {
Table,
Id,
Name,
Username,
PasswordDigest,
}

View file

@ -1,12 +0,0 @@
pub struct User {
pub id: Vec<u8>,
pub username: String,
pub name: Option<String>,
pub password_digest: Vec<u8>,
}
pub struct NewUser<'a> {
pub username: &'a str,
pub name: Option<&'a str>,
pub password_digest: Vec<u8>,
}

View file

@ -1,26 +1,22 @@
use std::{env, path::Path}; use crate::entities::{prelude::*, *};
use crate::state;
use argon2::{ use crate::{error::AppError, views};
password_hash::{rand_core::OsRng, SaltString}, use argon2::password_hash::rand_core::OsRng;
Argon2, PasswordHasher, use argon2::password_hash::SaltString;
}; use argon2::{Argon2, PasswordHasher};
use axum::{ use axum::extract::State;
http::StatusCode, use axum::{http::StatusCode, response::Html, routing::get, Form, Router};
response::{Html, IntoResponse},
routing::get,
Form, Router,
};
use axum_csrf::{CsrfConfig, CsrfLayer, CsrfToken}; use axum_csrf::{CsrfConfig, CsrfLayer, CsrfToken};
use base64::prelude::*;
use maud::html; use maud::html;
use notify::Watcher; use notify::Watcher;
use sea_orm::*;
use serde::Deserialize; use serde::Deserialize;
use std::sync::Arc;
use std::{env, path::Path};
use tower_http::services::ServeDir; use tower_http::services::ServeDir;
use tower_livereload::LiveReloadLayer; use tower_livereload::LiveReloadLayer;
use tracing::instrument; use tracing::{info, instrument};
use base64::prelude::*;
use crate::{error::AppError, models::NewUser, state::State, views};
#[instrument] #[instrument]
pub async fn new() -> Result<Router, anyhow::Error> { pub async fn new() -> Result<Router, anyhow::Error> {
@ -29,7 +25,7 @@ pub async fn new() -> Result<Router, anyhow::Error> {
.route("/hello-world-text", get(views::greet_world_text)); .route("/hello-world-text", get(views::greet_world_text));
let assets_dir = ServeDir::new("./assets"); let assets_dir = ServeDir::new("./assets");
let state = State::new().await?; let state = state::State::new().await?;
let live_reload_layer = LiveReloadLayer::new(); let live_reload_layer = LiveReloadLayer::new();
let reloader = live_reload_layer.reloader(); let reloader = live_reload_layer.reloader();
@ -58,6 +54,14 @@ pub async fn new() -> Result<Router, anyhow::Error> {
Ok(router) Ok(router)
} }
fn csrf_verify(c: CsrfToken, t: &str) -> Result<(), AppError> {
// TODO: https://docs.rs/axum_csrf/latest/axum_csrf/#prevent-post-replay-attacks-with-csrf
c.verify(t)?;
Ok(())
}
type AppRes = Result<(StatusCode, Html<String>), AppError>;
#[derive(Deserialize)] #[derive(Deserialize)]
struct Register { struct Register {
authenticity_token: String, authenticity_token: String,
@ -65,40 +69,52 @@ struct Register {
password: String, password: String,
} }
impl<'a> TryInto<NewUser<'a>> for &'a Register { impl TryInto<user::ActiveModel> for Register {
type Error = anyhow::Error; type Error = AppError;
fn try_into(self: &'a Register) -> Result<NewUser<'a>, Self::Error> { fn try_into(self) -> Result<user::ActiveModel, Self::Error> {
let salt = SaltString::generate(&mut OsRng); Ok(user::ActiveModel {
let argon2 = Argon2::default(); id: ActiveValue::Set(uuid::Uuid::now_v7().into()),
let password_digest: Vec<u8> = argon2 name: ActiveValue::Set(None),
.hash_password(self.password.as_bytes(), &salt)? username: ActiveValue::Set(self.username),
.hash password_digest: ActiveValue::Set(password_digest(self.password)?),
.expect("no password hash")
.as_bytes()
.into();
Ok(NewUser {
username: &self.username,
name: None,
password_digest,
}) })
} }
} }
fn password_digest<S>(s: S) -> Result<String, password_hash::Error>
where
S: AsRef<str>,
{
Ok(Argon2::default()
.hash_password(s.as_ref().as_bytes(), &SaltString::generate(&mut OsRng))?
.serialize()
.to_string())
}
impl user::ActiveModel {}
async fn register( async fn register(
c: CsrfToken, c: CsrfToken,
Form(register): Form<Register>, State(s): State<Arc<state::State>>,
) -> Result<impl IntoResponse, AppError> { Form(f): Form<Register>,
// TODO: https://docs.rs/axum_csrf/latest/axum_csrf/#prevent-post-replay-attacks-with-csrf ) -> AppRes {
c.verify(&register.authenticity_token)?; csrf_verify(c, &f.authenticity_token)?;
let new_user: NewUser = (&register).try_into()?; // TODO: handle duplicate username
let new: user::ActiveModel = f.try_into()?;
let res = User::insert(new).exec(&s.db).await;
info!("insert new user result: {:?}", res);
res.map_err(anyhow::Error::from)?;
Ok(( Ok((
StatusCode::CREATED, StatusCode::CREATED,
Html( Html(
html! { html! {
h1 { (new_user.username) } // h1 { (new_user.username) }
} }
.into_string(), .into_string(),
), ),
@ -112,27 +128,15 @@ struct Login {
password: String, password: String,
} }
async fn login( async fn login(c: CsrfToken, Form(f): Form<Login>) -> AppRes {
csrf_token: CsrfToken, csrf_verify(c, &f.authenticity_token)?;
Form(register): Form<Login>,
) -> Result<impl IntoResponse, AppError> {
// TODO: https://docs.rs/axum_csrf/latest/axum_csrf/#prevent-post-replay-attacks-with-csrf
let v = csrf_token.verify(&register.authenticity_token);
println!("{:?} {:?}", register.authenticity_token, v);
if v.is_err() {
return Ok((
StatusCode::BAD_REQUEST,
Html(html! { "invalid request" }.into_string()),
));
}
Ok(( Ok((
StatusCode::CREATED, StatusCode::OK,
Html( Html(
html! { html! {
h1 { (register.username) } h1 { (f.username) }
h1 { (register.password) } h1 { (f.password) }
} }
.into_string(), .into_string(),
), ),

View file

@ -1,12 +1,21 @@
use std::env; use std::{env, sync::Arc};
#[derive(Clone)] use sea_orm::{Database, DatabaseConnection};
pub struct State {} use sea_orm_migration::MigratorTrait;
use crate::migrator::Migrator;
pub struct State {
pub db: DatabaseConnection,
}
impl State { impl State {
pub async fn new() -> Result<Self, anyhow::Error> { pub async fn new() -> Result<Arc<Self>, anyhow::Error> {
let _database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
Ok(State {}) let db = Database::connect(database_url).await?;
Migrator::refresh(&db).await?;
Ok(Arc::new(State { db }))
} }
} }

View file

@ -1,17 +1,20 @@
use crate::entities::{prelude::*, *};
use crate::{
error::AppError,
partials::{footer, header},
state,
};
use axum::{ use axum::{
extract::State, extract::State,
http::StatusCode,
response::{Html, IntoResponse}, response::{Html, IntoResponse},
}; };
use axum_csrf::CsrfToken; use axum_csrf::CsrfToken;
use maud::html; use maud::html;
use sea_orm::EntityTrait;
use std::sync::Arc;
use tracing::instrument; use tracing::instrument;
use crate::{
error::AppError,
models::User,
partials::{footer, header},
};
pub async fn csrf<F>(csrf: CsrfToken, cb: F) -> impl IntoResponse pub async fn csrf<F>(csrf: CsrfToken, cb: F) -> impl IntoResponse
where where
F: Fn(&str) -> Html<String>, F: Fn(&str) -> Html<String>,
@ -20,6 +23,8 @@ where
(csrf, cb(&token)) (csrf, cb(&token))
} }
type AppRes = Result<(StatusCode, Html<String>), AppError>;
#[instrument] #[instrument]
pub async fn index() -> Html<String> { pub async fn index() -> Html<String> {
Html( Html(
@ -100,11 +105,8 @@ pub async fn login(t: CsrfToken) -> impl IntoResponse {
.await .await
} }
#[allow(unreachable_code)] pub async fn all_users(State(s): State<Arc<state::State>>) -> Result<Html<String>, AppError> {
pub async fn all_users( let users: Vec<user::Model> = User::find().all(&s.db).await?;
State(_state): State<crate::state::State>,
) -> Result<Html<String>, AppError> {
let all_users: Vec<User> = vec![];
// @if let Some(name) = u.name { // @if let Some(name) = u.name {
// name // name
@ -117,13 +119,17 @@ pub async fn all_users(
main class="prose" { main class="prose" {
h1 { "Users" } h1 { "Users" }
ul { ul {
@for u in all_users { @if users.len() < 1 {
li { li { "It looks like there are no users yet!" }
(u.username) } else {
@if let Some(name) = u.name { @for u in users {
" (" li {
(name) (u.username)
")" @if let Some(name) = u.name {
" ("
(name)
")"
}
} }
} }
} }