208 lines
5.8 KiB
Rust
208 lines
5.8 KiB
Rust
#![warn(clippy::pedantic)]
|
|
#![allow(clippy::unused_async)]
|
|
|
|
mod tasks;
|
|
mod users;
|
|
|
|
use axum::extract::Extension;
|
|
use axum::http::StatusCode;
|
|
use axum::response::Html;
|
|
use axum::response::IntoResponse;
|
|
use axum::response::Redirect;
|
|
use axum::response::Response;
|
|
use axum::{
|
|
routing::{get, post},
|
|
Router,
|
|
};
|
|
use axum_sessions::extractors::ReadableSession;
|
|
use axum_sessions::{async_session::MemoryStore, SessionLayer};
|
|
use serde::Deserialize;
|
|
use sqlx::postgres::PgPoolOptions;
|
|
use sqlx::{Pool, Postgres};
|
|
use std::sync::Arc;
|
|
use tera::Tera;
|
|
use thiserror::Error as ThisError;
|
|
use tower::ServiceBuilder;
|
|
|
|
#[derive(ThisError, Debug)]
|
|
pub enum Error {
|
|
Database(#[from] sqlx::Error),
|
|
Tera(#[from] tera::Error),
|
|
Tokio(#[from] tokio::task::JoinError),
|
|
Pbkdf2(pbkdf2::password_hash::Error),
|
|
Session(#[from] axum_sessions::async_session::serde_json::Error),
|
|
Io(#[from] std::io::Error),
|
|
Toml(#[from] toml::de::Error),
|
|
Other(String),
|
|
Redirect(Redirect),
|
|
StatusCode(StatusCode),
|
|
}
|
|
|
|
impl std::fmt::Display for Error {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
use std::fmt::Display;
|
|
#[allow(clippy::enum_glob_use)]
|
|
use Error::*;
|
|
match self {
|
|
Database(e) => Display::fmt(e, f),
|
|
Tera(e) => Display::fmt(e, f),
|
|
Tokio(e) => Display::fmt(e, f),
|
|
Pbkdf2(e) => Display::fmt(e, f),
|
|
Session(e) => Display::fmt(e, f),
|
|
Io(e) => Display::fmt(e, f),
|
|
Toml(e) => Display::fmt(e, f),
|
|
Other(e) => write!(f, "Error: {}", e),
|
|
Redirect(_) => write!(f, "[redirect]"),
|
|
StatusCode(c) => Display::fmt(c, f),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl IntoResponse for Error {
|
|
fn into_response(self) -> Response {
|
|
match self {
|
|
Self::Redirect(r) => r.into_response(),
|
|
_ => self.to_string().into_response(),
|
|
}
|
|
}
|
|
}
|
|
|
|
macro_rules! manual_from {
|
|
($($f:ty => $t:ident),*) => {
|
|
$(
|
|
impl From<$f> for Error {
|
|
fn from(source: $f) -> Self {
|
|
Self::$t(source)
|
|
}
|
|
}
|
|
)*
|
|
};
|
|
}
|
|
|
|
// We need to implement these manually because they don't meet the bounds set by thiserror
|
|
manual_from! {
|
|
pbkdf2::password_hash::Error => Pbkdf2,
|
|
String => Other,
|
|
Redirect => Redirect,
|
|
StatusCode => StatusCode
|
|
}
|
|
|
|
#[derive(Deserialize, Debug, Clone)]
|
|
pub struct Config {
|
|
secret: String,
|
|
connection_string: String,
|
|
template_dir: Option<String>,
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<(), Error> {
|
|
let filename = std::env::var("TMTD_CONFIG").unwrap_or_else(|_| "config.toml".into());
|
|
let contents = std::fs::read_to_string(filename)?;
|
|
let config: Config = toml::from_str(&contents)?;
|
|
let pool = Arc::new(
|
|
PgPoolOptions::new()
|
|
.connect(&config.connection_string)
|
|
.await?,
|
|
);
|
|
|
|
sqlx::query("create table if not exists users(id serial primary key, username text not null unique, password_hash text not null)").execute(&*pool).await?;
|
|
sqlx::query("create table if not exists tasks(id serial primary key, owner int not null, title text not null, description text not null, status int not null)").execute(&*pool).await?;
|
|
|
|
let tera = Arc::new(Tera::new(&format!(
|
|
"{}/**/*.html",
|
|
config.template_dir.as_deref().unwrap_or("templates")
|
|
))?);
|
|
|
|
let store = MemoryStore::new();
|
|
if config.secret.len() < 64 {
|
|
return Err("Secret must be at least 64 bytes!".to_string().into());
|
|
}
|
|
let session_layer =
|
|
SessionLayer::new(store, config.secret.as_bytes()).with_cookie_name("2m2d_session");
|
|
|
|
let task_routes = Router::new()
|
|
.route("/update/:id", get(tasks::update_form))
|
|
.route("/update/:id", post(tasks::update_backend))
|
|
.route("/create", get(tasks::create_form))
|
|
.route("/create", post(tasks::create_backend));
|
|
|
|
let app = Router::new()
|
|
.route("/", get(homepage))
|
|
.route("/register", post(users::create_user))
|
|
.route("/register", get(users::register_form))
|
|
.route("/login", get(users::login_form))
|
|
.route("/login", post(users::login_backend))
|
|
.nest("/tasks", task_routes)
|
|
.layer(
|
|
ServiceBuilder::new()
|
|
.layer(Extension(pool))
|
|
.layer(Extension(tera))
|
|
.layer(session_layer),
|
|
);
|
|
|
|
axum::Server::bind(&"0.0.0.0:3000".parse().unwrap())
|
|
.serve(app.into_make_service())
|
|
.await
|
|
.unwrap();
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[macro_export]
|
|
macro_rules! login_or_redirect {
|
|
($session:expr, $to:literal) => {
|
|
match $session.get::<String>("logged_in_as") {
|
|
Some(username) => username,
|
|
None => return Err(::axum::response::Redirect::to($to).into()),
|
|
}
|
|
};
|
|
}
|
|
|
|
#[macro_export]
|
|
macro_rules! ctx {
|
|
($($key:literal => $val:expr),*) => {{
|
|
let mut ctx = ::tera::Context::new();
|
|
|
|
$(
|
|
ctx.insert($key, &$val);
|
|
)+
|
|
|
|
ctx
|
|
}};
|
|
}
|
|
|
|
#[derive(sqlx::FromRow, serde::Serialize, serde::Deserialize)]
|
|
pub struct Task {
|
|
pub title: String,
|
|
pub description: String,
|
|
pub status: i32,
|
|
}
|
|
|
|
async fn homepage(
|
|
session: ReadableSession,
|
|
Extension(tera): Extension<Arc<Tera>>,
|
|
Extension(pool): Extension<Arc<Pool<Postgres>>>,
|
|
) -> Result<Html<String>, Error> {
|
|
let username = login_or_redirect!(session, "/login");
|
|
|
|
let (id,): (i32,) = sqlx::query_as("select id from users where username=$1")
|
|
.bind(&username)
|
|
.fetch_one(&*pool)
|
|
.await?;
|
|
|
|
let tasks: Vec<Task> =
|
|
sqlx::query_as("select title,description,status from tasks where owner=$1")
|
|
.bind(id)
|
|
.fetch_all(&*pool)
|
|
.await?;
|
|
|
|
let rendered = tera.render(
|
|
"home.html",
|
|
&ctx! {
|
|
"tasks" => tasks
|
|
},
|
|
)?;
|
|
|
|
Ok(Html(rendered))
|
|
}
|