mod gate;
mod map;
mod today;
mod why;
use crate::failure::{Failure, R};
use crate::output::{flush, outln};
use crate::project::{Located, Named, 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, &'a str),
NotFound,
}
fn route(path: &str) -> Route<'_> {
let (path, query) = match path.split_once('?') {
Some((p, q)) => (p, q),
None => (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, query);
}
}
_ => {}
}
}
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 boot_redirect(request: tiny_http::Request, token: &str, landing: Option<&str>) {
let jar = format!(
"{}={token}; Path=/; HttpOnly; SameSite=Strict",
SESSION_COOKIE
);
let location = match landing {
Some(id) => format!("/p/{id}/"),
None => "/".to_string(),
};
redirect_with(request, &location, 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);
}
pub(crate) fn unreachable_sentence(names: &[String]) -> String {
format!(
"{} project{} unreachable: {}",
names.len(),
if names.len() == 1 { "" } else { "s" },
names.join(", ")
)
}
fn not_one(request: tiny_http::Request, registry: &mut Registry, named: Named) {
match named {
Named::Ambiguous(which) => {
let page = today::choose_page(registry.all(), &which);
respond(request, 300, HTML, page)
}
_ => respond(
request,
404,
TEXT,
"not found
"
.to_string(),
),
}
}
fn handle(
gate: &mut Gate,
registry: &mut Registry,
landing: Option<&str>,
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(), landing),
Verdict::Serve => match route(&path) {
Route::Index => {
let unreachable = registry.unreachable().to_vec();
let page = today::index_page(registry.all(), &unreachable);
respond(request, 200, HTML, page)
}
Route::Today(id) => match registry.named(id) {
Named::One(i) => {
let project = registry.at(i);
let name = project.name.clone();
let key = id.to_string();
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(),
),
}
}
other => not_one(request, registry, other),
},
Route::Why(id, node) => match registry.named(id) {
Named::One(i) => {
let project = registry.at(i);
let name = project.name.clone();
let key = id.to_string();
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(),
),
}
}
other => not_one(request, registry, other),
},
Route::Tree(id, query) => match registry.named(id) {
Named::One(i) => {
let project = registry.at(i);
let name = project.name.clone();
let key = id.to_string();
match project.current() {
Ok(ctx) => respond(
request,
200,
HTML,
map::map_page(&key, &name, &ctx.tree, query),
),
Err(_) => respond(
request,
500,
TEXT,
"the store could not be read\n".to_string(),
),
}
}
other => not_one(request, registry, other),
},
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(
required: Vec<PathBuf>,
optional: Vec<PathBuf>,
cwd_located: Option<Located>,
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 cwd_root = cwd_located.as_ref().map(|l| l.root.clone());
let here = cwd_located.map(|l| {
let root = l.root.clone();
(root, l)
});
let mut registry = Registry::open(required, optional, here)?;
let landing: Option<String> = cwd_root.and_then(|c| {
let key = std::fs::canonicalize(&c).unwrap_or(c);
registry
.all()
.iter()
.find(|p| std::fs::canonicalize(&p.root).unwrap_or_else(|_| p.root.clone()) == key)
.map(|p| {
p.ulid()
.map(str::to_string)
.unwrap_or_else(|| p.slug.clone())
})
});
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}");
let unreachable = registry.unreachable();
if !unreachable.is_empty() {
outln!(" {}", unreachable_sentence(unreachable));
}
flush();
if open {
open_browser(&url);
}
loop {
let request = server.recv().map_err(Failure::Io)?;
handle(&mut gate, &mut registry, landing.as_deref(), 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", "")));
assert!(matches!(
route("/p/vivac/tree?fold=g1"),
Route::Tree("vivac", "fold=g1")
));
}
#[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));
}
}