Added web ui with templates

This commit is contained in:
Manuel Forcén Muñoz 2024-02-19 23:51:18 +01:00
parent d2cb5b3031
commit 90b02eef79
24 changed files with 1403 additions and 78 deletions

View file

@ -10,7 +10,8 @@ serde = { workspace = true, features = ["derive"] }
chrono = { workspace = true, features = ["serde"] }
sqlx = { workspace = true, features = ["runtime-tokio", "sqlite", "chrono"]}
tokio = { version = "1", features = ["full"] }
axum = { version = "0.6.20", features = ["macros", "headers"] }
axum = { version = "0.6.20", features = ["macros", "headers", "form"] }
hyper = "0.14.27"
serde_json = "1"
accounters = { path = ".." }
tera = "1.19.1"

View file

@ -9,6 +9,7 @@ use axum::{
Router,
};
use hyper::StatusCode;
use tera::Tera;
mod routes;
mod users;
@ -19,38 +20,56 @@ const DB_URL: &str = "sqlite://sqlite.db";
async fn main() {
let db = accounters::create_db(DB_URL).await.unwrap();
let state = AppState { db: Arc::new(db) };
let mut tmpls = Tera::new("templates/*").unwrap();
tmpls.autoescape_on(vec!["html"]);
let state = AppState {
db: Arc::new(db),
tmpls: Arc::new(tmpls),
};
let app = Router::new()
.route("/", get(index))
.nest(
"/",
Router::new()
.route("/", get(routes::ui::index))
.route("/accounts/id/:id", get(routes::ui::account))
.route("/rules", get(routes::ui::rules::list))
.route("/rules/new", get(routes::ui::rules::new_view))
.route("/rules/new", post(routes::ui::rules::new_action))
.nest(
"/static",
Router::new().route("/styles.css", get(routes::static_routes::styles)),
),
)
.nest(
"/api/v1",
Router::new()
.route("/user", post(routes::create_user))
.route("/login", post(routes::login))
.route("/accounts", post(routes::accounts::account_create))
.route("/accounts", get(routes::accounts::account_list))
.route("/accounts/id/:id", get(routes::accounts::account_get))
.route("/user", post(routes::api::create_user))
.route("/login", post(routes::api::login))
.route("/accounts", post(routes::api::accounts::account_create))
.route("/accounts", get(routes::api::accounts::account_list))
.route("/accounts/id/:id", get(routes::api::accounts::account_get))
.route(
"/accounts/id/:id/transaction",
post(routes::transactions::create),
post(routes::api::transactions::create),
)
.route(
"/accounts/id/:id/transaction",
get(routes::transactions::list),
get(routes::api::transactions::list),
)
.route(
"/accounts/id/:id/update",
post(routes::accounts::snapshot_update),
post(routes::api::accounts::snapshot_update),
)
.route(
"/accounts/id/:id/recategorize",
post(routes::accounts::recategorize),
post(routes::api::accounts::recategorize),
)
.route("/categories", post(routes::categories::create))
.route("/categories", get(routes::categories::list))
.route("/rules", post(routes::rules::create))
.route("/rules", get(routes::rules::list)),
.route("/categories", post(routes::api::categories::create))
.route("/categories", get(routes::api::categories::list))
.route("/rules", post(routes::api::rules::create))
.route("/rules", get(routes::api::rules::list)),
)
.with_state(state);
@ -64,6 +83,7 @@ async fn main() {
#[derive(Clone)]
pub struct AppState {
db: Arc<SqlitePool>,
tmpls: Arc<Tera>,
}
impl FromRef<AppState> for Arc<SqlitePool> {
@ -72,6 +92,12 @@ impl FromRef<AppState> for Arc<SqlitePool> {
}
}
impl FromRef<AppState> for Arc<Tera> {
fn from_ref(state: &AppState) -> Arc<Tera> {
state.tmpls.clone()
}
}
async fn index() -> (StatusCode, String) {
(StatusCode::OK, String::from("Hello, World!"))
}

View file

@ -1,43 +1,3 @@
use std::sync::Arc;
use axum::extract::{Json, State};
use hyper::StatusCode;
use serde::Deserialize;
use sqlx::SqlitePool;
use accounters::models::users::User;
pub mod accounts;
pub mod categories;
pub mod rules;
pub mod transactions;
#[derive(Deserialize)]
pub struct CreateUserRequest {
user: String,
pass: String,
}
pub async fn create_user(
State(db): State<Arc<SqlitePool>>,
Json(user_info): Json<CreateUserRequest>,
) -> (StatusCode, String) {
let exec = User::create_user(db.as_ref(), &user_info.user, &user_info.pass).await;
match exec {
Ok(e) => (StatusCode::OK, format!("{}", e.get_id())),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")),
}
}
pub async fn login(
State(db): State<Arc<SqlitePool>>,
Json(user_info): Json<CreateUserRequest>,
) -> (StatusCode, String) {
let user = User::get_user(db.as_ref(), &user_info.user).await.unwrap();
if user.check_pass(&user_info.pass) {
(StatusCode::OK, format!("{}", user.get_id()))
} else {
(StatusCode::UNAUTHORIZED, String::new())
}
}
pub mod api;
pub mod static_routes;
pub mod ui;

View file

@ -0,0 +1,43 @@
use std::sync::Arc;
use axum::extract::{Json, State};
use hyper::StatusCode;
use serde::Deserialize;
use sqlx::SqlitePool;
use accounters::models::users::User;
pub mod accounts;
pub mod categories;
pub mod rules;
pub mod transactions;
#[derive(Deserialize)]
pub struct CreateUserRequest {
user: String,
pass: String,
}
pub async fn create_user(
State(db): State<Arc<SqlitePool>>,
Json(user_info): Json<CreateUserRequest>,
) -> (StatusCode, String) {
let exec = User::create_user(db.as_ref(), &user_info.user, &user_info.pass).await;
match exec {
Ok(e) => (StatusCode::OK, format!("{}", e.get_id())),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")),
}
}
pub async fn login(
State(db): State<Arc<SqlitePool>>,
Json(user_info): Json<CreateUserRequest>,
) -> (StatusCode, String) {
let user = User::get_user(db.as_ref(), &user_info.user).await.unwrap();
if user.check_pass(&user_info.pass) {
(StatusCode::OK, format!("{}", user.get_id()))
} else {
(StatusCode::UNAUTHORIZED, String::new())
}
}

View file

@ -0,0 +1,12 @@
use std::fs;
use axum::response::IntoResponse;
use hyper::{header::CONTENT_TYPE, StatusCode};
pub async fn styles() -> impl IntoResponse {
(
StatusCode::OK,
[(CONTENT_TYPE, "text/css")],
fs::read_to_string("static/styles.css").unwrap(),
)
}

102
webserver/src/routes/ui.rs Normal file
View file

@ -0,0 +1,102 @@
use std::sync::Arc;
use axum::{
extract::{Path, Query, State},
response::IntoResponse,
};
use hyper::{header::CONTENT_TYPE, StatusCode};
use serde::Deserialize;
use sqlx::SqlitePool;
use tera::{Context, Tera};
use crate::users::UserToken;
use accounters::models::{Account, Transaction};
pub mod rules;
pub async fn index(
State(db): State<Arc<SqlitePool>>,
State(tmpls): State<Arc<Tera>>,
uid: UserToken,
) -> impl IntoResponse {
let mut ctx = Context::new();
let accounts = Account::list(db.as_ref(), uid.user_id).await.unwrap();
ctx.insert("accounts", &accounts);
match tmpls.render("index.html", &ctx) {
Ok(out) => (
StatusCode::OK,
[(CONTENT_TYPE, "text/html;charset=utf-8")],
out,
),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
[(CONTENT_TYPE, "text/plain")],
format!("{e}"),
),
}
}
#[derive(Deserialize)]
pub struct AccountViewParams {
movements: Option<i32>,
}
pub async fn account(
State(db): State<Arc<SqlitePool>>,
State(tmpls): State<Arc<Tera>>,
uid: UserToken,
Path(account_id): Path<i32>,
Query(AccountViewParams { movements }): Query<AccountViewParams>,
) -> impl IntoResponse {
let mut ctx = Context::new();
let account = match Account::get_by_id(db.as_ref(), account_id).await {
Ok(a) => a,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
[(CONTENT_TYPE, "text/plain")],
format!("{e}"),
);
}
};
if account.get_user() != uid.user_id {
return (
StatusCode::UNAUTHORIZED,
[(CONTENT_TYPE, "text/plain")],
String::from("You cannot access this resource"),
);
}
let txs = match Transaction::list(
db.as_ref(),
account.get_id(),
movements.unwrap_or(10),
0,
false,
)
.await
{
Ok(t) => t,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
[(CONTENT_TYPE, "text/plain")],
format!("Error at loading transactions: {e}"),
);
}
};
ctx.insert("account", &account);
ctx.insert("transactions", &txs);
ctx.insert("n_txs", &txs.len());
(
StatusCode::OK,
[(CONTENT_TYPE, "text/html;charset=utf-8")],
tmpls.render("accounts.html", &ctx).unwrap(),
)
}

View file

@ -0,0 +1,84 @@
use std::sync::Arc;
use accounters::models::{categories::Category, rules::Rule};
use axum::{
extract::{Form, State},
response::IntoResponse,
};
use hyper::{header::CONTENT_TYPE, StatusCode};
use serde::Deserialize;
use sqlx::SqlitePool;
use tera::{Context, Tera};
use crate::users::UserToken;
pub async fn list(
State(db): State<Arc<SqlitePool>>,
State(tmpls): State<Arc<Tera>>,
uid: UserToken,
) -> impl IntoResponse {
let rules = match Rule::list_by_user(db.as_ref(), uid.user_id).await {
Ok(r) => r,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
[(CONTENT_TYPE, "text/plain")],
format!("{e:?}"),
);
}
};
let mut ctx = Context::new();
ctx.insert("rules", &rules);
(
StatusCode::OK,
[(CONTENT_TYPE, "text/html;charset=utf-8")],
tmpls.render("rules_list.html", &ctx).unwrap(),
)
}
pub async fn new_view(
State(db): State<Arc<SqlitePool>>,
State(tmpls): State<Arc<Tera>>,
uid: UserToken,
) -> impl IntoResponse {
let categories = Category::list(db.as_ref()).await.unwrap();
let mut ctx = Context::new();
ctx.insert("categories", &categories);
(
StatusCode::OK,
[(CONTENT_TYPE, "text/html;charset=utf-8")],
tmpls.render("rules_new.html", &ctx).unwrap(),
)
}
#[derive(Deserialize)]
pub struct NewRuleParams {
pub description: String,
pub regex: String,
pub category: i32,
}
pub async fn new_action(
State(db): State<Arc<SqlitePool>>,
State(tmpls): State<Arc<Tera>>,
uid: UserToken,
Form(params): Form<NewRuleParams>,
) -> impl IntoResponse {
match Rule::new(db.as_ref(), uid.user_id, params.regex, params.category).await {
Ok(_) => (
StatusCode::OK,
[(CONTENT_TYPE, "text/html;charset=utf-8")],
tmpls
.render("rules_new_success.html", &Context::new())
.unwrap(),
),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
[(CONTENT_TYPE, "text/plain;charset=utf-8")],
format!("{e}"),
),
}
}

View file

@ -1,12 +1,18 @@
use std::sync::Arc;
use accounters::models::users::User;
use axum::{
async_trait,
extract::FromRequestParts,
headers::authorization::Bearer,
extract::{FromRef, FromRequestParts},
headers::authorization::{Basic, Bearer},
headers::Authorization,
http::request::Parts,
response::{IntoResponse, Redirect},
RequestPartsExt, TypedHeader,
};
use hyper::StatusCode;
use crate::AppState;
pub struct AuthRedirect;
@ -23,20 +29,35 @@ pub struct UserToken {
#[async_trait]
impl<S> FromRequestParts<S> for UserToken
where
AppState: FromRef<S>,
S: Send + Sync,
{
type Rejection = AuthRedirect;
type Rejection = (StatusCode, [(&'static str, &'static str); 1]);
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let auth = parts
.extract::<TypedHeader<Authorization<Bearer>>>()
.await
.map_err(|e| panic!("Could not get cookies: {e}"))
.unwrap();
let ut = UserToken {
user_id: auth.0 .0.token().parse().unwrap(),
};
Ok(ut)
match parts.extract::<TypedHeader<Authorization<Bearer>>>().await {
Ok(auth) => Ok(UserToken {
user_id: auth.0 .0.token().parse().unwrap(),
}),
Err(_) => match parts.extract::<TypedHeader<Authorization<Basic>>>().await {
Ok(auth) => {
let state = AppState::from_ref(state);
let user = User::get_user(state.db.as_ref(), auth.username())
.await
.unwrap();
if user.check_pass(auth.password()) {
Ok(UserToken {
user_id: user.get_id(),
})
} else {
Err((StatusCode::UNAUTHORIZED, [("", "")]))
}
}
Err(_) => Err((
StatusCode::UNAUTHORIZED,
[("WWW-Authenticate", "Basic realm=\"Access\"")],
)),
},
}
}
}