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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,3 +1,3 @@
|
|||||||
.cargo/
|
.cargo/
|
||||||
/target
|
/target
|
||||||
tasks.txt
|
app.db
|
158
Cargo.lock
generated
158
Cargo.lock
generated
@@ -1,6 +1,6 @@
|
|||||||
# This file is automatically @generated by Cargo.
|
# This file is automatically @generated by Cargo.
|
||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 3
|
version = 4
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "adler32"
|
name = "adler32"
|
||||||
@@ -38,6 +38,55 @@ dependencies = [
|
|||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "ascii"
|
name = "ascii"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -56,6 +105,15 @@ version = "0.13.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
|
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bincode"
|
||||||
|
version = "1.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.6.0"
|
version = "2.6.0"
|
||||||
@@ -128,7 +186,10 @@ checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"android-tzdata",
|
"android-tzdata",
|
||||||
"iana-time-zone",
|
"iana-time-zone",
|
||||||
|
"js-sys",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
|
"serde",
|
||||||
|
"wasm-bindgen",
|
||||||
"windows-targets",
|
"windows-targets",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -138,6 +199,52 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
|
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]]
|
[[package]]
|
||||||
name = "core-foundation-sys"
|
name = "core-foundation-sys"
|
||||||
version = "0.8.7"
|
version = "0.8.7"
|
||||||
@@ -240,6 +347,12 @@ dependencies = [
|
|||||||
"crc32fast",
|
"crc32fast",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hermit-abi"
|
name = "hermit-abi"
|
||||||
version = "0.3.9"
|
version = "0.3.9"
|
||||||
@@ -420,6 +533,12 @@ dependencies = [
|
|||||||
"icu_properties",
|
"icu_properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.14"
|
version = "1.0.14"
|
||||||
@@ -671,6 +790,15 @@ dependencies = [
|
|||||||
"getrandom",
|
"getrandom",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redb"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "84b1de48a7cf7ba193e81e078d17ee2b786236eed1d3f7c60f8a09545efc4925"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.7"
|
version = "0.5.7"
|
||||||
@@ -785,6 +913,12 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
|
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strsim"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.90"
|
version = "2.0.90"
|
||||||
@@ -876,8 +1010,15 @@ dependencies = [
|
|||||||
name = "todo-app"
|
name = "todo-app"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bincode",
|
||||||
|
"chrono",
|
||||||
|
"clap",
|
||||||
"maud",
|
"maud",
|
||||||
|
"redb",
|
||||||
"rouille",
|
"rouille",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -924,6 +1065,21 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
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]]
|
[[package]]
|
||||||
name = "version_check"
|
name = "version_check"
|
||||||
version = "0.9.5"
|
version = "0.9.5"
|
||||||
|
@@ -3,8 +3,13 @@ name = "todo-app"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
bincode = "1.3.3"
|
||||||
|
chrono = { version = "0.4.38", features = ["serde"] }
|
||||||
|
clap = { version = "4.5.21", features = ["derive"] }
|
||||||
maud = "0.26.0"
|
maud = "0.26.0"
|
||||||
|
redb = "2.2.0"
|
||||||
rouille = "3.6.2"
|
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
76
src/database.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
123
src/main.rs
123
src/main.rs
@@ -1,95 +1,42 @@
|
|||||||
use std::{
|
mod database;
|
||||||
env,
|
mod server;
|
||||||
fs::OpenOptions,
|
mod task;
|
||||||
io::{BufRead, BufReader, Write},
|
|
||||||
};
|
|
||||||
|
|
||||||
use maud::{html, Markup, DOCTYPE};
|
use clap::{CommandFactory, Parser, Subcommand};
|
||||||
use rouille::{input::basic_http_auth, post_input, router, try_or_400, Request, Response};
|
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() {
|
fn main() {
|
||||||
let login: String = env::var("LOGIN").unwrap();
|
let cli = Cli::parse();
|
||||||
let password: String = env::var("PASSWORD").unwrap();
|
let database = AppDatabase::create().unwrap();
|
||||||
|
match cli.command {
|
||||||
rouille::start_server("0.0.0.0:8000", move |request| {
|
Some(Commands::AddUser { username, password }) => {
|
||||||
if !authenticated(&request, &login, &password) {
|
database.add_user(&username, &password).unwrap();
|
||||||
return Response::basic_http_auth_login_required("app");
|
println!("Added user {}", username);
|
||||||
}
|
|
||||||
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()) }
|
|
||||||
}
|
}
|
||||||
|
Some(Commands::Serve { port }) => {
|
||||||
|
serve(port.unwrap_or(DEFAULT_PORT), database);
|
||||||
|
}
|
||||||
|
None => Cli::command().print_help().unwrap(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"short_name": "To-do",
|
|
||||||
"start_url": "/",
|
|
||||||
"display": "standalone",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "/icon.png",
|
|
||||||
"sizes": "512x512"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
154
src/server.rs
Normal file
154
src/server.rs
Normal 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", 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
80
src/task.rs
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user