use { axum::{ http::{header::COOKIE, request::Parts}, response::{AppendHeaders, Html}, }, core::fmt, htmlm::{html, write_html}, serde::{Deserialize, Serialize}, std::net::Ipv4Addr, }; const MAX_NAME_LENGTH: usize = 32; const MAX_POSTNAME_LENGTH: usize = 64; //const MAX_CODE_LENGTH: usize = 1024 * 4; const SESSION_DURATION_SECS: u64 = 60 * 60; type Redirect<const COUNT: usize = 1> = AppendHeaders<[(&'static str, &'static str); COUNT]>; async fn amain() { use axum::routing::{delete, get, post}; let debug = cfg!(debug_assertions); log::set_logger(&Logger).unwrap(); log::set_max_level(if debug { log::LevelFilter::Warn } else { log::LevelFilter::Error }); db::init(); let router = axum::Router::new() .route("/", get(Index::page)) .route( "/hbfmt.wasm", get(|| async move { axum::http::Response::builder() .header("content-type", "application/wasm") .body(axum::body::Body::from( include_bytes!("../../target/wasm32-unknown-unknown/small/wasm_hbfmt.wasm") .to_vec(), )) .unwrap() }), ) .route("/index-view", get(Index::get)) .route("/feed", get(Index::page)) .route("/profile", get(Profile::page)) .route("/profile-view", get(Profile::get)) .route("/post", get(Post::page)) .route("/post-view", get(Post::get)) .route("/post", post(Post::post)) .route("/login", get(Login::page)) .route("/login-view", get(Login::get)) .route("/login", post(Login::post)) .route("/login", delete(Login::delete)) .route("/signup", get(Signup::page)) .route("/signup-view", get(Signup::get)) .route("/signup", post(Signup::post)) .route( "/hot-reload", get({ let id = std::time::SystemTime::now() .duration_since(std::time::SystemTime::UNIX_EPOCH) .unwrap() .as_millis(); move || async move { id.to_string() } }), ); let socket = tokio::net::TcpListener::bind((Ipv4Addr::UNSPECIFIED, 8080)).await.unwrap(); axum::serve(socket, router).await.unwrap(); } trait PublicPage: Default { fn render(self) -> String; async fn get() -> Html<String> { Html(Self::default().render()) } async fn page(session: Option<Session>) -> Html<String> { base(Self::default().render(), session).await } } trait Page: Default { fn render(self, session: &Session) -> String; async fn get(session: Session) -> Html<String> { Html(Self::default().render(&session)) } async fn page(session: Session) -> Html<String> { base(Self::default().render(&session), Some(session)).await } } #[derive(Default)] struct Index; impl PublicPage for Index { fn render(self) -> String { include_str!("welcome-page.html").to_string() } } #[derive(Deserialize, Default)] struct Post { author: String, name: String, #[serde(skip)] timestamp: u64, #[serde(skip)] imports: usize, #[serde(skip)] runs: usize, #[serde(skip)] dependencies: usize, code: String, #[serde(skip)] error: Option<&'static str>, } impl Page for Post { fn render(self, session: &Session) -> String { let Self { name, code, error, .. } = self; html! { <form id="postForm" "hx-post"="/post" "hx-swap"="outherHTML"> if let Some(e) = error { <div class="error">e</div> } <input name="author" type="text" value={session.name} hidden> <input name="name" type="text" placeholder="name" value=name required maxlength=MAX_POSTNAME_LENGTH> <textarea name="code" placeholder="code" rows=1 required>code</textarea> <input type="submit" value="submit"> </form> !{include_str!("post-page.html")} } } } impl Post { async fn post( session: Session, axum::Form(mut data): axum::Form<Self>, ) -> Result<Redirect, Html<String>> { db::with(|db| { if let Err(e) = db.create_post.insert((&data.name, &session.name, now(), &data.code)) { if let rusqlite::Error::SqliteFailure(e, _) = e { if e.code == rusqlite::ErrorCode::ConstraintViolation { data.error = Some("this name is already used"); } } data.error = data.error.or_else(|| { log::error!("{e}"); Some("internal server error") }); } }); if data.error.is_some() { Err(Html(data.render(&session))) } else { Ok(redirect("/profile")) } } } impl fmt::Display for Post { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let Self { author, name, timestamp, imports, runs, dependencies, code, .. } = self; write_html! { f <div class="preview"> <div class="info"> <span>author "/" name</span> <span apply="timestamp">timestamp</span> </div> <div class="stats"> for (name, count) in "inps runs deps".split(' ') .zip([imports, runs, dependencies]) .filter(|(_, &c)| c != 0) { name ": "<span>count</span> } </div> <pre apply="fmt">code</pre> if *timestamp == 0 { <button "hx-get"="/post" "hx-swap"="outherHTML" "hx-target"="[preview]">"edit"</button> } </div> } Ok(()) } } #[derive(Default)] struct Profile; impl Page for Profile { fn render(self, session: &Session) -> String { db::with(|db| { let iter = db .get_user_posts .query_map((&session.name,), |r| { Ok(Post { author: r.get(0)?, name: r.get(1)?, timestamp: r.get(2)?, code: r.get(3)?, ..Default::default() }) }) .inspect_err(|e| log::error!("{e}")) .into_iter() .flatten() .filter_map(|p| p.inspect_err(|e| log::error!("{e}")).ok()); html! { for post in iter { !{post} } else { "no posts" } !{include_str!("profile-page.html")} } }) } } #[derive(Serialize, Deserialize, Default)] struct Login { name: String, password: String, #[serde(skip)] error: Option<&'static str>, } impl PublicPage for Login { fn render(self) -> String { let Login { name, password, error } = self; html! { <form "hx-post"="/login" "hx-swap"="outherHTML"> if let Some(e) = error { <div class="error">e</div> } <input name="name" type="text" autocomplete="name" placeholder="name" value=name required maxlength=MAX_NAME_LENGTH> <input name="password" type="password" autocomplete="password" placeholder="password" value=password> <input type="submit" value="submit"> </form> } } } impl Login { async fn post( axum::Form(mut data): axum::Form<Self>, ) -> Result<AppendHeaders<[(&'static str, String); 2]>, Html<String>> { // TODO: hash password let mut id = [0u8; 32]; db::with(|db| match db.authenticate.query((&data.name, &data.password)) { Ok(mut r) => { if r.next().map_or(true, |v| v.is_none()) { data.error = Some("invalid credentials"); } else { getrandom::getrandom(&mut id).unwrap(); if let Err(e) = db.login.insert((id, &data.name, now() + SESSION_DURATION_SECS)) { log::error!("{e}"); } } } Err(e) => { log::error!("{e}"); data.error = Some("internal server error"); } }); if data.error.is_some() { Err(Html(data.render())) } else { Ok(AppendHeaders([ ("hx-location", "/feed".into()), ( "set-cookie", format!( "id={}; SameSite=Strict; Secure; Max-Age={SESSION_DURATION_SECS}", to_hex(&id) ), ), ])) } } async fn delete(session: Session) -> Redirect { _ = db::with(|q| q.logout.execute((session.id,)).inspect_err(|e| log::error!("{e}"))); redirect("/login") } } #[derive(Serialize, Deserialize, Default)] struct Signup { name: String, new_password: String, confirm_password: String, #[serde(skip)] error: Option<&'static str>, } impl PublicPage for Signup { fn render(self) -> String { let Signup { name, new_password, confirm_password, error } = self; html! { <form "hx-post"="/signup" "hx-swap"="outherHTML"> if let Some(e) = error { <div class="error">e</div> } <input name="name" type="text" autocomplete="name" placeholder="name" value=name maxlength=MAX_NAME_LENGTH required> <input name="new_password" type="password" autocomplete="new-password" placeholder="new password" value=new_password> <input name="confirm_password" type="password" autocomplete="confirm-password" placeholder="confirm password" value=confirm_password> <input type="submit" value="submit"> </form> } } } impl Signup { async fn post(axum::Form(mut data): axum::Form<Self>) -> Result<Redirect, Html<String>> { db::with(|db| { // TODO: hash passwords match db.register.insert((&data.name, &data.new_password)) { Ok(_) => {} Err(rusqlite::Error::SqliteFailure(e, _)) if e.code == rusqlite::ErrorCode::ConstraintViolation => { data.error = Some("username already taken"); } Err(e) => { log::error!("{e}"); data.error = Some("internal server error"); } }; }); if data.error.is_some() { Err(Html(data.render())) } else { Ok(redirect("/login")) } } } async fn base(body: String, session: Option<Session>) -> Html<String> { let username = session.map(|s| s.name); Html(htmlm::html! { "<!DOCTIPE>" <html lang="en"> <head> <style>!{include_str!("index.css")}</style> </head> <body> <nav> <button "hx-push-url"="/" "hx-get"="/index-view" "hx-target"="main" "hx-swap"="innerHTML">"depell"</button> <section> if let Some(username) = username { <button "hx-push-url"="/profile" "hx-get"="/profile-view" "hx-target"="main" "hx-swap"="innerHTML">username</button> <button "hx-push-url"="/post" "hx-get"="/post-view" "hx-target"="main" "hx-swap"="innerHTML">"post"</button> <button "hx-delete"="/login">"logout"</button> } else { <button "hx-push-url"="/login" "hx-get"="/login-view" "hx-target"="main" "hx-swap"="innerHTML">"login"</button> <button "hx-push-url"="/signup" "hx-get"="/signup-view" "hx-target"="main" "hx-swap"="innerHTML">"signup"</button> } </section> </nav> <section id="post-form"></section> <main>!{body}</main> </body> <script src="https://unpkg.com/htmx.org@2.0.3" integrity="sha384-0895/pl2MU10Hqc6jd4RvrthNlDiE9U1tWmX7WRESftEDRosgxNsQG/Ze9YMRzHq" crossorigin="anonymous"></script> <script>!{include_str!("index.js")}</script> </html> }) } struct Session { name: String, id: [u8; 32], } #[axum::async_trait] impl<S> axum::extract::FromRequestParts<S> for Session { /// If the extractor fails it'll use this "rejection" type. A rejection is /// a kind of error that can be converted into a response. type Rejection = Redirect; /// Perform the extraction. async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> { let err = redirect("/login"); let value = parts .headers .get_all(COOKIE) .into_iter() .find_map(|c| c.to_str().ok()?.trim().strip_prefix("id=")) .map(|c| c.split_once(';').unwrap_or((c, "")).0) .ok_or(err)?; let mut id = [0u8; 32]; parse_hex(value, &mut id).ok_or(err)?; let (name, expiration) = db::with(|db| { db.get_session .query_row((dbg!(id),), |r| Ok((r.get::<_, String>(0)?, r.get::<_, u64>(1)?))) .inspect_err(|e| log::error!("{e}")) .map_err(|_| err) })?; if expiration < now() { log::error!("expired"); return Err(err); } Ok(Self { name, id }) } } fn now() -> u64 { std::time::SystemTime::now() .duration_since(std::time::SystemTime::UNIX_EPOCH) .unwrap() .as_secs() } fn parse_hex(hex: &str, dst: &mut [u8]) -> Option<()> { fn hex_to_nibble(b: u8) -> Option<u8> { Some(match b { b'a'..=b'f' => b - b'a' + 10, b'A'..=b'F' => b - b'A' + 10, b'0'..=b'9' => b - b'0', _ => return None, }) } if hex.len() != dst.len() * 2 { return None; } for (d, p) in dst.iter_mut().zip(hex.as_bytes().chunks_exact(2)) { *d = (hex_to_nibble(p[0])? << 4) | hex_to_nibble(p[1])?; } Some(()) } fn to_hex(src: &[u8]) -> String { use std::fmt::Write; let mut buf = String::new(); for &b in src { write!(buf, "{b:02x}").unwrap() } buf } fn main() { tokio::runtime::Builder::new_current_thread().enable_io().build().unwrap().block_on(amain()); } mod db { use std::cell::RefCell; macro_rules! gen_queries { ($vis:vis struct $name:ident { $($qname:ident: $code:literal,)* }) => { #[allow(dead_code)] $vis struct $name<'a> { $($vis $qname: rusqlite::Statement<'a>,)* } impl<'a> $name<'a> { fn new(db: &'a rusqlite::Connection) -> Self { Self { $($qname: db.prepare($code).unwrap(),)* } } } }; } gen_queries! { pub struct Queries { register: "INSERT INTO user (name, password_hash) VALUES(?, ?)", authenticate: "SELECT name, password_hash FROM user WHERE name = ? AND password_hash = ?", login: "INSERT OR REPLACE INTO session (id, username, expiration) VALUES(?, ?, ?)", logout: "DELETE FROM session WHERE id = ?", get_session: "SELECT username, expiration FROM session WHERE id = ?", get_user_posts: "SELECT author, name, timestamp, code FROM post WHERE author = ?", create_post: "INSERT INTO post (name, author, timestamp, code) VALUES(?, ?, ?, ?)", } } struct Db { queries: Queries<'static>, _db: Box<rusqlite::Connection>, } impl Db { fn new() -> Self { let db = Box::new(rusqlite::Connection::open("db.sqlite").unwrap()); Self { queries: Queries::new(unsafe { std::mem::transmute::<&rusqlite::Connection, &rusqlite::Connection>(&db) }), _db: db, } } } pub fn with<T>(with: impl FnOnce(&mut Queries) -> T) -> T { thread_local! { static DB_CONN: RefCell<Db> = RefCell::new(Db::new()); } DB_CONN.with_borrow_mut(|q| with(&mut q.queries)) } pub fn init() { let db = rusqlite::Connection::open("db.sqlite").unwrap(); db.execute_batch(include_str!("schema.sql")).unwrap(); Queries::new(&db); } } fn redirect(to: &'static str) -> Redirect { AppendHeaders([("hx-location", to)]) } struct Logger; impl log::Log for Logger { fn enabled(&self, _: &log::Metadata) -> bool { true } fn log(&self, record: &log::Record) { if self.enabled(record.metadata()) { eprintln!("{} - {}", record.module_path().unwrap_or("=="), record.args()); } } fn flush(&self) {} }