mod gate;
mod today;
mod tree;
mod why;
use crate::failure::{Failure, R};
use crate::output::{flush, outln};
use crate::project::Registry;
use gate::{Denial, Gate, Incoming, Verdict, SESSION_COOKIE};
use std::path::PathBuf;
const HTML: &str = "text/html; charset=utf-8";
const TEXT: &str = "text/plain; charset=utf-8";
const CSP: &str = "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; \
img-src data:; form-action 'none'; frame-ancestors 'none'; base-uri 'none'";
fn header(name: &str, value: &str) -> tiny_http::Header {
tiny_http::Header::from_bytes(name.as_bytes(), value.as_bytes())
.expect("a header built from a literal name and an ASCII value")
}
fn header_value<'a>(headers: &'a [tiny_http::Header], name: &'static str) -> Option<&'a str> {
headers
.iter()
.find(|h| h.field.equiv(name))
.map(|h| h.value.as_str())
}
pub(crate) const WEB_CSS: &str = include_str!("web.css");
pub(crate) fn escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
out
}
pub(crate) fn alias_link(project: &str, alias: &str) -> String {
let alias = escape(alias);
format!(
"<a href=\"/p/{p}/why/{alias}\">{alias}</a>",
p = escape(project)
)
}
enum Route<'a> {
Index,
Today(&'a str),
Why(&'a str, &'a str),
Tree(&'a str),
NotFound,
}
fn route(path: &str) -> Route<'_> {
let path = path.split_once('?').map(|(p, _)| p).unwrap_or(path);
if path == "/" {
return Route::Index;
}
if let Some(rest) = path.strip_prefix("/p/") {
let rest = rest.strip_suffix('/').unwrap_or(rest);
match rest.split_once('/') {
None if !rest.is_empty() => return Route::Today(rest),
Some((id, tail)) if !id.is_empty() => {
if let Some(node) = tail.strip_prefix("why/") {
if !node.is_empty() && !node.contains('/') {
return Route::Why(id, node);
}
} else if tail == "tree" {
return Route::Tree(id);
}
}
_ => {}
}
}
Route::NotFound
}
fn security_headers() -> [tiny_http::Header; 4] {
[
header("Content-Security-Policy", CSP),
header("X-Content-Type-Options", "nosniff"),
header("Referrer-Policy", "no-referrer"),
header("Cache-Control", "no-store"),
]
}
fn respond(request: tiny_http::Request, status: u16, content_type: &str, body: String) {
let mut response = tiny_http::Response::from_string(body)
.with_status_code(status)
.with_header(header("Content-Type", content_type));
for h in security_headers() {
response = response.with_header(h);
}
let _ = request.respond(response);
}
fn redirect(request: tiny_http::Request, location: &str) {
redirect_with(request, location, None)
}
fn boot_redirect(request: tiny_http::Request, token: &str) {
let jar = format!(
"{}={token}; Path=/; HttpOnly; SameSite=Strict",
SESSION_COOKIE
);
redirect_with(request, "/", Some(header("Set-Cookie", &jar)))
}
fn redirect_with(request: tiny_http::Request, location: &str, extra: Option<tiny_http::Header>) {
let mut response = tiny_http::Response::from_string(String::new())
.with_status_code(302)
.with_header(header("Location", location));
for h in security_headers() {
response = response.with_header(h);
}
if let Some(h) = extra {
response = response.with_header(h);
}
let _ = request.respond(response);
}
fn handle(gate: &mut Gate, registry: &mut Registry, request: tiny_http::Request) {
let path = request.url().to_string();
let host = header_value(request.headers(), "host").map(str::to_string);
let origin = header_value(request.headers(), "origin").map(str::to_string);
let token = header_value(request.headers(), "x-vivac-token").map(str::to_string);
let cookie = header_value(request.headers(), "cookie").map(str::to_string);
let incoming = Incoming {
path: &path,
host: host.as_deref(),
origin: origin.as_deref(),
token: token.as_deref(),
cookie: cookie.as_deref(),
};
match gate.admit(&incoming) {
Verdict::Boot => boot_redirect(request, gate.token()),
Verdict::Serve => match route(&path) {
Route::Index => match registry.projects() {
[one] => redirect(request, &format!("/p/{}/", one.id)),
many => respond(request, 200, HTML, today::index_page(many)),
},
Route::Today(id) => match registry.by_id(id) {
Some(project) => {
let name = project.name.clone();
let key = project.id.clone();
match project.current_with_log() {
Ok((ctx, log)) => {
let page = today::today_page(&key, &name, &ctx.tree, log);
respond(request, 200, HTML, page)
}
Err(_) => respond(
request,
500,
TEXT,
"the store could not be read\n".to_string(),
),
}
}
None => respond(request, 404, TEXT, "not found\n".to_string()),
},
Route::Why(id, node) => match registry.by_id(id) {
Some(project) => {
let name = project.name.clone();
let key = project.id.clone();
match project.current_with_log() {
Ok((ctx, log)) => match why::why_page(&key, &name, &ctx.tree, log, node) {
Some(page) => respond(request, 200, HTML, page),
None => respond(request, 404, TEXT, "not found\n".to_string()),
},
Err(_) => respond(
request,
500,
TEXT,
"the store could not be read\n".to_string(),
),
}
}
None => respond(request, 404, TEXT, "not found\n".to_string()),
},
Route::Tree(id) => match registry.by_id(id) {
Some(project) => {
let name = project.name.clone();
let key = project.id.clone();
match project.current() {
Ok(ctx) => {
respond(request, 200, HTML, tree::tree_page(&key, &name, &ctx.tree))
}
Err(_) => respond(
request,
500,
TEXT,
"the store could not be read\n".to_string(),
),
}
}
None => respond(request, 404, TEXT, "not found\n".to_string()),
},
Route::NotFound => respond(request, 404, TEXT, "not found\n".to_string()),
},
Verdict::Deny(Denial::ForeignHost) | Verdict::Deny(Denial::ForeignOrigin) => {
respond(request, 403, TEXT, "forbidden\n".to_string())
}
Verdict::Deny(Denial::NoValidToken) => respond(
request,
401,
TEXT,
"no session. run: vivac web\n".to_string(),
),
}
}
fn open_browser(url: &str) {
let launched = if cfg!(target_os = "windows") {
std::process::Command::new("cmd")
.args(["/C", "start", "", url])
.status()
} else if cfg!(target_os = "macos") {
std::process::Command::new("open").arg(url).status()
} else {
std::process::Command::new("xdg-open").arg(url).status()
};
let _ = launched;
}
pub fn serve(roots: Vec<PathBuf>, port: Option<u16>, open: bool) -> R {
let server = tiny_http::Server::http(("127.0.0.1", port.unwrap_or(0)))
.map_err(|e| Failure::Io(std::io::Error::other(e)))?;
let bound_port = server
.server_addr()
.to_ip()
.map(|a| a.port())
.ok_or_else(|| Failure::usage("vivac web needs a TCP address to bind"))?;
let mut gate = Gate::new(bound_port)?;
let mut registry = Registry::open(roots)?;
let url = gate.boot_url();
outln!(" vivac web listening on http://127.0.0.1:{bound_port}");
outln!(" open this to start a session: {url}");
flush();
if open {
open_browser(&url);
}
loop {
let request = server.recv().map_err(Failure::Io)?;
handle(&mut gate, &mut registry, request);
}
}
#[cfg(test)]
mod tests {
use super::{escape, route, Route};
#[test]
fn the_characters_that_can_change_a_page_are_escaped() {
assert_eq!(
escape("<b>a & \"b\" 'c'</b>"),
"<b>a & "b" 'c'</b>"
);
}
#[test]
fn text_with_nothing_to_escape_comes_back_whole() {
assert_eq!(escape("vivac-project"), "vivac-project");
}
#[test]
fn a_node_title_cannot_close_the_tag_it_sits_in() {
assert!(!escape("</li><script>alert(1)</script>").contains('<'));
}
#[test]
fn the_root_path_routes_to_the_index() {
assert!(matches!(route("/"), Route::Index));
}
#[test]
fn a_query_string_does_not_change_where_the_root_routes() {
assert!(matches!(route("/?k=abc"), Route::Index));
}
#[test]
fn a_projects_today_page_routes_with_or_without_a_trailing_slash() {
assert!(matches!(route("/p/vivac/"), Route::Today("vivac")));
assert!(matches!(route("/p/vivac"), Route::Today("vivac")));
}
#[test]
fn a_path_under_a_project_that_does_not_exist_yet_is_not_found() {
assert!(matches!(route("/p/vivac/op/push"), Route::NotFound));
}
#[test]
fn a_lineage_routes_under_its_project() {
assert!(matches!(
route("/p/vivac/why/f4"),
Route::Why("vivac", "f4")
));
assert!(matches!(
route("/p/vivac/why/f4/"),
Route::Why("vivac", "f4")
));
}
#[test]
fn a_tree_routes_under_its_project() {
assert!(matches!(route("/p/vivac/tree"), Route::Tree("vivac")));
assert!(matches!(route("/p/vivac/tree/"), Route::Tree("vivac")));
}
#[test]
fn a_lineage_path_with_no_node_on_it_is_not_found() {
assert!(matches!(route("/p/vivac/why/"), Route::NotFound));
assert!(matches!(route("/p/vivac/why"), Route::NotFound));
assert!(matches!(route("/p//why/f4"), Route::NotFound));
}
#[test]
fn an_empty_id_is_not_found() {
assert!(matches!(route("/p/"), Route::NotFound));
}
#[test]
fn a_percent_encoded_id_is_not_decoded_and_so_matches_nothing_real() {
assert!(matches!(route("/p/%76ivac/"), Route::Today("%76ivac")));
}
#[test]
fn an_unrecognised_path_is_not_found() {
assert!(matches!(route("/other"), Route::NotFound));
}
}