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
This commit is contained in:
2024-12-05 00:01:35 -08:00
parent d228788df0
commit b142432544
8 changed files with 510 additions and 103 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,3 @@
.cargo/
/target
tasks.txt
app.db

158
Cargo.lock generated
View File

@@ -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"

View File

@@ -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"] }

76
src/database.rs Normal file
View File

@@ -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<uuid::Bytes, Task> = TableDefinition::new("tasks");
pub struct AppDatabase(Database);
impl AppDatabase {
pub fn create() -> Result<Self, redb::Error> {
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<Option<String>, 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<String, redb::Error> {
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<Option<String>, 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<Uuid, redb::Error> {
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<Vec<Task>, 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::<Vec<_>>();
Ok(tasks)
}
}

View File

@@ -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<Commands>,
}
#[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<u16>,
},
}
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(),
}
}

View File

@@ -1,11 +0,0 @@
{
"short_name": "To-do",
"start_url": "/",
"display": "standalone",
"icons": [
{
"src": "/icon.png",
"sizes": "512x512"
}
]
}

154
src/server.rs Normal file
View File

@@ -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; Max-Age=31536000", 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()) }
}
}
}

80
src/task.rs Normal file
View File

@@ -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<Utc>),
Abandoned(DateTime<Utc>),
}
#[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<u8>
where
Self: 'a;
fn fixed_width() -> Option<usize> {
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")
}
}