use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use ullun::cookies::SameSite;
use ullun::prelude::*;
use ullun::security::random_token;
type Sessions = Arc<Mutex<HashMap<String, String>>>;
fn escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
fn page(title: &str, flash: Option<&str>, body: &str) -> Response {
let flash = flash
.map(|m| format!("<p style=\"color:#b00\">{}</p>", escape(m)))
.unwrap_or_default();
Response::ok()
.with_header("content-type", "text/html; charset=utf-8")
.with_body(format!(
"<!doctype html><meta charset=\"utf-8\"><title>{t}</title>\
<body style=\"font-family:system-ui;margin:3rem\"><h1>{t}</h1>{flash}{body}</body>",
t = escape(title)
))
}
fn redirect(to: &str) -> Response {
Response::new(http::StatusCode::SEE_OTHER).with_header("location", to)
}
#[derive(Deserialize)]
struct LoginForm {
user: String,
password: String,
}
fn flash_cookie(msg: &str) -> Cookie {
let safe: String = msg
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == ' ')
.collect();
Cookie::new("flash", safe.replace(' ', "_"))
.max_age(10)
.http_only()
}
fn take_flash(req: &Request) -> Option<String> {
req.cookies().get("flash").map(|v| v.replace('_', " "))
}
fn clear_flash() -> Cookie {
Cookie::new("flash", "").max_age(0)
}
#[tokio::main]
async fn main() {
let sessions: Sessions = Arc::default();
let s = sessions.clone();
let login_page = |req: Request| async move {
let flash = take_flash(&req);
Ok(page(
"Sign in",
flash.as_deref(),
"<form method=\"post\" action=\"/login\">\
<input name=\"user\" placeholder=\"name\" required> \
<input name=\"password\" type=\"password\" placeholder=\"password (demo)\" required> \
<button>Sign in</button></form>",
)
.cookie(clear_flash()))
};
let login = move |req: Request| {
let s = s.clone();
async move {
let f: LoginForm = req.form()?;
let user = f.user.trim().to_string();
if user.is_empty() || f.password != "demo" {
return Ok(redirect("/").cookie(flash_cookie("Wrong name or password")));
}
let sid = random_token(16)?;
s.lock()
.expect("session lock poisoned")
.insert(sid.clone(), user);
Ok(redirect("/dashboard").cookie(
Cookie::new("sid", sid)
.http_only()
.same_site(SameSite::Lax)
.max_age(3600),
))
}
};
let s = sessions.clone();
let dashboard = move |req: Request| {
let s = s.clone();
async move {
let user = req
.cookies()
.get("sid")
.and_then(|sid| s.lock().expect("session lock poisoned").get(sid).cloned());
match user {
Some(user) => Ok(page(
&format!("Hello, {user}"),
None,
"<form method=\"post\" action=\"/logout\"><button>Sign out</button></form>",
)),
None => Ok(redirect("/").cookie(flash_cookie("Please sign in first"))),
}
}
};
let s = sessions;
let logout = move |req: Request| {
let s = s.clone();
async move {
if let Some(sid) = req.cookies().get("sid") {
s.lock().expect("session lock poisoned").remove(sid);
}
Ok(redirect("/").cookie(Cookie::new("sid", "").max_age(0)))
}
};
let addr = format!(
"127.0.0.1:{}",
std::env::var("PORT").unwrap_or_else(|_| "3000".into())
);
App::new()
.max_body_size(Some(16 * 1024))
.get("/", login_page)
.post("/login", login)
.get("/dashboard", dashboard)
.post("/logout", logout)
.run(&addr)
.await
.unwrap();
}