From 6f6d5bcebfcb38a907aea3f78aff8dae3d888d20 Mon Sep 17 00:00:00 2001 From: Nettika Date: Thu, 5 Dec 2024 00:01:35 -0800 Subject: [PATCH] Use an embedded database - Add a CLI with subcommands for "serve" and "add-user" - Inline the manifest with serde_json - Move the server to a separate file - Change login to token cookies - Persist the task form between loads --- .gitignore | 2 +- Cargo.lock | 158 +++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 9 ++- src/database.rs | 76 ++++++++++++++++++++++ src/main.rs | 123 ++++++++++-------------------------- src/manifest.json | 11 ---- src/server.rs | 154 ++++++++++++++++++++++++++++++++++++++++++++ src/task.rs | 80 +++++++++++++++++++++++ 8 files changed, 510 insertions(+), 103 deletions(-) create mode 100644 src/database.rs delete mode 100644 src/manifest.json create mode 100644 src/server.rs create mode 100644 src/task.rs diff --git a/.gitignore b/.gitignore index fcc5995..2945765 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ .cargo/ /target -tasks.txt \ No newline at end of file +app.db \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 981a4b3..4b13f80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "adler32" @@ -38,6 +38,55 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" +dependencies = [ + "anstyle", + "windows-sys 0.59.0", +] + [[package]] name = "ascii" version = "1.1.0" @@ -56,6 +105,15 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "2.6.0" @@ -128,7 +186,10 @@ checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", + "serde", + "wasm-bindgen", "windows-targets", ] @@ -138,6 +199,52 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" +[[package]] +name = "clap" +version = "4.5.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb3b4b9e5a7c7514dfa52869339ee98b3156b0bfb4e8a77c4ff4babb64b1604f" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b17a95aa67cc7b5ebd32aa5370189aa0d79069ef1c64ce893bd30fb24bff20ec" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afb84c814227b90d6895e01398aee0d8033c00e7466aca416fb6a8e0eb19d8a7" + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -240,6 +347,12 @@ dependencies = [ "crc32fast", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.3.9" @@ -420,6 +533,12 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + [[package]] name = "itoa" version = "1.0.14" @@ -671,6 +790,15 @@ dependencies = [ "getrandom", ] +[[package]] +name = "redb" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84b1de48a7cf7ba193e81e078d17ee2b786236eed1d3f7c60f8a09545efc4925" +dependencies = [ + "libc", +] + [[package]] name = "redox_syscall" version = "0.5.7" @@ -785,6 +913,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.90" @@ -876,8 +1010,15 @@ dependencies = [ name = "todo-app" version = "0.1.0" dependencies = [ + "bincode", + "chrono", + "clap", "maud", + "redb", "rouille", + "serde", + "serde_json", + "uuid", ] [[package]] @@ -924,6 +1065,21 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" +dependencies = [ + "getrandom", +] + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index 10aa9d5..ba94f0e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,8 +3,13 @@ name = "todo-app" version = "0.1.0" edition = "2021" -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - [dependencies] +bincode = "1.3.3" +chrono = { version = "0.4.38", features = ["serde"] } +clap = { version = "4.5.21", features = ["derive"] } maud = "0.26.0" +redb = "2.2.0" rouille = "3.6.2" +serde = { version = "1.0.215", features = ["derive"] } +serde_json = "1.0.133" +uuid = { version = "1.11.0", features = ["v4", "v7"] } diff --git a/src/database.rs b/src/database.rs new file mode 100644 index 0000000..bfaac4c --- /dev/null +++ b/src/database.rs @@ -0,0 +1,76 @@ +use redb::{Database, ReadableTable, TableDefinition}; +use uuid::Uuid; + +use crate::task::Task; + +const DATABASE_FILE: &str = "app.db"; +const USERS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("users"); +const AUTH_TABLE: TableDefinition<&str, &str> = TableDefinition::new("sessions"); +const TASKS_TABLE: TableDefinition = TableDefinition::new("tasks"); + +pub struct AppDatabase(Database); + +impl AppDatabase { + pub fn create() -> Result { + let database = Database::create(DATABASE_FILE)?; + let transaction = database.begin_write()?; + transaction.open_table(USERS_TABLE)?; + transaction.open_table(AUTH_TABLE)?; + transaction.open_table(TASKS_TABLE)?; + transaction.commit()?; + Ok(Self(database)) + } + + pub fn add_user(&self, username: &str, password: &str) -> Result<(), redb::Error> { + let transaction = self.0.begin_write()?; + let mut users_table = transaction.open_table(USERS_TABLE)?; + users_table.insert(username, password)?; + drop(users_table); + transaction.commit()?; + Ok(()) + } + + pub fn get_user(&self, username: &str) -> Result, redb::Error> { + let transaction = self.0.begin_read()?; + let users_table = transaction.open_table(USERS_TABLE)?; + let user = users_table.get(username)?.map(|g| g.value().to_string()); + Ok(user) + } + + pub fn create_auth_token(&self, username: &str) -> Result { + let token = Uuid::new_v4().to_string(); + let transaction = self.0.begin_write()?; + let mut auth_table = transaction.open_table(AUTH_TABLE)?; + auth_table.insert(token.as_str(), username)?; + drop(auth_table); + transaction.commit()?; + Ok(token) + } + + pub fn get_auth_user(&self, token: &str) -> Result, redb::Error> { + let transaction = self.0.begin_read()?; + let auth_table = transaction.open_table(AUTH_TABLE)?; + let user = auth_table.get(token)?.map(|g| g.value().to_string()); + Ok(user) + } + + pub fn add_task(&self, task: Task) -> Result { + let id = Uuid::now_v7(); + let transaction = self.0.begin_write()?; + let mut tasks_table = transaction.open_table(TASKS_TABLE)?; + tasks_table.insert(id.as_bytes(), task)?; + drop(tasks_table); + transaction.commit()?; + Ok(id) + } + + pub fn get_tasks(&self) -> Result, redb::Error> { + let transaction = self.0.begin_read()?; + let tasks_table = transaction.open_table(TASKS_TABLE)?; + let tasks = tasks_table + .iter()? + .map(|g| g.unwrap().1.value()) + .collect::>(); + Ok(tasks) + } +} diff --git a/src/main.rs b/src/main.rs index 7014fb6..a262afe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,95 +1,42 @@ -use std::{ - env, - fs::OpenOptions, - io::{BufRead, BufReader, Write}, -}; +mod database; +mod server; +mod task; -use maud::{html, Markup, DOCTYPE}; -use rouille::{input::basic_http_auth, post_input, router, try_or_400, Request, Response}; +use clap::{CommandFactory, Parser, Subcommand}; +use database::AppDatabase; +use server::serve; -const TASKS: &str = "tasks.txt"; +const DEFAULT_PORT: u16 = 8000; + +#[derive(Parser, Debug)] +#[command(name = "todo", about = "Todo list web app")] +struct Cli { + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand, Debug)] +enum Commands { + #[command(name = "add-user", about = "Add an authorized user")] + AddUser { username: String, password: String }, + #[command(name = "serve", about = "Serve the web app")] + Serve { + /// Defaults to 8000 + port: Option, + }, +} fn main() { - let login: String = env::var("LOGIN").unwrap(); - let password: String = env::var("PASSWORD").unwrap(); - - rouille::start_server("0.0.0.0:8000", move |request| { - if !authenticated(&request, &login, &password) { - return Response::basic_http_auth_login_required("app"); - } - router!(request, - (GET) ["/"] => { index() }, - (GET) ["/manifest.json"] => { - Response::from_data("application/manifest+json", include_bytes!("manifest.json")) - }, - (GET) ["/icon.png"] => { - Response::from_data("image/png", include_bytes!("icon.png")) - }, - (POST) ["/add"] => { - let input = try_or_400!(post_input!(request, { - task: String, - })); - add_task(&input.task); - Response::html(render_tasks()) - }, - _ => Response::empty_404() - ) - }); -} - -fn authenticated(request: &Request, login: &str, password: &str) -> bool { - match basic_http_auth(request) { - Some(a) => a.login == login && a.password == password, - None => false, - } -} - -fn index() -> Response { - Response::html(html!( - (DOCTYPE) - html lang="en" color-mode="user" { - head { - meta charset="utf-8"; - meta name="viewport" content="width=device-width, initial-scale=1"; - title { "To-do" } - link rel="stylesheet" href="https://unpkg.com/mvp.css@1.17.0"; - link rel="manifest" href="/manifest.json" crossorigin="use-credentials"; - link rel="icon" href="/icon.png" crossorigin="use-credentials"; - script src="https://unpkg.com/htmx.org@2.0.3" {} - } - body { - main { - h1 { "To-do" } - form hx-post="/add" hx-target="#list" hx-swap="innerHTML" hx-on:submit="this.elements.task.value = ''" { - input type="text" name="task" placeholder="Task" required; - input type="submit" value="Add"; - } - ul id="list" { - (render_tasks()) - } - } - } - } - )) -} - -fn add_task(task: &str) { - let mut file = OpenOptions::new() - .create(true) - .append(true) - .open(TASKS) - .unwrap(); - writeln!(file, "{}", task).unwrap(); -} - -fn render_tasks() -> Markup { - let reader = match OpenOptions::new().read(true).open(TASKS) { - Ok(file) => BufReader::new(file), - Err(_) => return html! {}, - }; - html! { - @for line in reader.lines() { - li { (line.unwrap()) } + let cli = Cli::parse(); + let database = AppDatabase::create().unwrap(); + match cli.command { + Some(Commands::AddUser { username, password }) => { + database.add_user(&username, &password).unwrap(); + println!("Added user {}", username); } + Some(Commands::Serve { port }) => { + serve(port.unwrap_or(DEFAULT_PORT), database); + } + None => Cli::command().print_help().unwrap(), } } diff --git a/src/manifest.json b/src/manifest.json deleted file mode 100644 index fe33582..0000000 --- a/src/manifest.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "short_name": "To-do", - "start_url": "/", - "display": "standalone", - "icons": [ - { - "src": "/icon.png", - "sizes": "512x512" - } - ] -} \ No newline at end of file diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..5e0898a --- /dev/null +++ b/src/server.rs @@ -0,0 +1,154 @@ +use std::io::stdout; + +use maud::{html, Markup, PreEscaped, DOCTYPE}; +use rouille::{post_input, router, try_or_400, Request, Response}; +use serde_json::json; + +use crate::{ + database::AppDatabase, + task::{Task, TaskPriority, TaskStatus}, +}; + +pub fn serve(port: u16, database: AppDatabase) { + println!("Server started at http://localhost:{}", port); + rouille::start_server(format!("0.0.0.0:{}", port), move |request| { + rouille::log(request, stdout(), || route(request, &database)) + }); +} + +fn route(request: &Request, database: &AppDatabase) -> Response { + let token = rouille::input::cookies(request) + .find(|c| c.0 == "token") + .map(|c| c.1); + let authenticated = token.is_some_and(|t| database.get_auth_user(t).unwrap().is_some()); + router!(request, + (GET) ["/"] => match authenticated { + true => Response::html(render_index()), + false => Response::redirect_302("/login") + }, + (GET) ["/login"] => { + Response::html(render_login()) + }, + (GET) ["/manifest.json"] => { + Response::json(&json!({ + "short_name": "To-do", + "start_url": "/", + "display": "standalone", + "icons": [ + { + "src": "/icon.png", + "sizes": "512x512" + } + ] + })) + }, + (GET) ["/icon.png"] => { + Response::from_data("image/png", include_bytes!("icon.png")) + }, + (POST) ["/login"] => { + let input = try_or_400!(post_input!(request, { + username: String, + password: String, + })); + if database.get_user(&input.username).unwrap() == Some(input.password) { + let token = database.create_auth_token(&input.username).unwrap(); + Response::empty_204() + .with_additional_header("Set-Cookie", format!("token={}; HttpOnly; Secure; SameSite=Strict", token)) + .with_additional_header("HX-Redirect", "/") + } else { + Response::text("Invalid credentials") + } + }, + (GET) ["/tasks"] => match authenticated { + true => Response::html(render_ongoing_tasks(database)), + false => Response::text("Unauthorized") + .with_status_code(401) + .with_additional_header("HX-Redirect", "/login") + }, + (POST) ["/add_task"] => match authenticated { + true => { + let input = try_or_400!(post_input!(request, { + task: String, + })); + database.add_task(Task::new(&input.task, TaskStatus::Ongoing, TaskPriority::Low)).unwrap(); + Response::html(render_ongoing_tasks(database)) + } + false => Response::text("Unauthorized") + .with_status_code(401) + .with_additional_header("HX-Redirect", "/login") + }, + _ => Response::empty_404() + ) +} + +fn render_page(title: &str, content: Markup) -> Markup { + html! { + (DOCTYPE) + html lang="en" color-mode="user" { + head { + meta charset="utf-8"; + meta name="viewport" content="width=device-width, initial-scale=1"; + title { (title) } + link rel="stylesheet" href="https://unpkg.com/mvp.css@1.17.0"; + link rel="manifest" href="/manifest.json"; + link rel="icon" href="/icon.png"; + script src="https://unpkg.com/htmx.org@2.0.3" {} + script src="https://unpkg.com/form-persistence@2.0.6" {} + } + body { + (content) + } + } + } +} + +fn render_index() -> Markup { + render_page( + "To-do", + html! { + main { + h1 { "To-do" } + form #add-task hx-post="/add_task" hx-target="#list" hx-swap="innerHTML" { + input #task type="text" name="task" placeholder="Task" required; + input type="submit" value="Add"; + } + script { + (PreEscaped( + r#"document.addEventListener("DOMContentLoaded",()=>FormPersistence.persist(document.getElementById("add-task")));document.addEventListener("htmx:afterRequest",event=>event.detail.successful&&document.getElementById("add-task").reset())"# + )) + } + ul #list hx-get="/tasks" hx-trigger="load" {} + } + }, + ) +} + +fn render_login() -> Markup { + render_page( + "Login", + html! { + main { + h1 { "Login" } + form hx-post="/login" hx-target="#message" hx-swap="innerHTML" { + input type="text" name="username" placeholder="Username" required; + input type="password" name="password" placeholder="Password" required; + input type="submit" value="Login"; + } + p #message style="color: red;" {} + } + }, + ) +} + +fn render_ongoing_tasks(database: &AppDatabase) -> Markup { + let tasks = database + .get_tasks() + .unwrap() + .into_iter() + .filter(|t| !t.is_done()); + html! { + @for task in tasks { + li { (task.description()) } + } + } +} diff --git a/src/task.rs b/src/task.rs new file mode 100644 index 0000000..c3174df --- /dev/null +++ b/src/task.rs @@ -0,0 +1,80 @@ +use chrono::{DateTime, Utc}; +use redb::{TypeName, Value}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum TaskStatus { + Ongoing, + Completed(DateTime), + Abandoned(DateTime), +} + +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum TaskPriority { + Low, + Medium, + High, +} + +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Task { + description: String, + status: TaskStatus, + priority: TaskPriority, +} + +impl Task { + pub fn new(description: &str, status: TaskStatus, priority: TaskPriority) -> Self { + Task { + description: description.to_string(), + status, + priority, + } + } + + pub fn description(&self) -> &str { + &self.description + } + + pub fn is_done(&self) -> bool { + matches!( + self.status, + TaskStatus::Completed(_) | TaskStatus::Abandoned(_) + ) + } +} + +impl Value for Task { + type SelfType<'a> + = Task + where + Self: 'a; + + type AsBytes<'a> + = Vec + where + Self: 'a; + + fn fixed_width() -> Option { + None + } + + fn from_bytes<'a>(data: &'a [u8]) -> Self::SelfType<'a> + where + Self: 'a, + { + bincode::deserialize(data).unwrap() + } + + fn as_bytes<'a, 'b: 'a>(value: &'a Self::SelfType<'b>) -> Self::AsBytes<'a> + where + Self: 'a, + Self: 'b, + { + bincode::serialize(value).unwrap() + } + + fn type_name() -> TypeName { + TypeName::new("Task") + } +}