Files
todo-app/src/server.rs
Nettika 6f6d5bcebf 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
2024-12-05 00:03:22 -08:00

155 lines
5.3 KiB
Rust

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()) }
}
}
}