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) const FAVICON: &str = concat!(
"<link rel=\"icon\" type=\"image/svg+xml\" href=\"data:image/svg+xml;base64,",
"PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjUgNSA1",
"NCA1NCI+PHN0eWxlPi5pe2ZpbGw6IzE2MTgxZH0ucntzdHJva2U6IzJmNWQ1MH1AbWVkaWEg",
"KHByZWZlcnMtY29sb3Itc2NoZW1lOmRhcmspey5pe2ZpbGw6I2U2ZTVlMX0ucntzdHJva2U6",
"IzdmYmZhOX19PC9zdHlsZT48cGF0aCBjbGFzcz0iciIgZD0iTTE3IDE3VjM2UTE3IDQ3IDI4",
"IDQ3SDQ2IiBmaWxsPSJub25lIiBzdHJva2Utd2lkdGg9IjcuNSIgc3Ryb2tlLWxpbmVjYXA9",
"InJvdW5kIi8+PGNpcmNsZSBjbGFzcz0iaSIgY3g9IjE3IiBjeT0iMTYiIHI9IjguNSIvPjxj",
"aXJjbGUgY2xhc3M9ImkiIGN4PSI0OCIgY3k9IjQ3IiByPSI4LjUiLz48L3N2Zz4=",
"\">"
);
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 {
const FAVICON_SVG: &str = concat!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"5 5 54 54\"><style",
">.i{fill:#16181d}.r{stroke:#2f5d50}@media (prefers-color-scheme:dark){",
".i{fill:#e6e5e1}.r{stroke:#7fbfa9}}</style><path class=\"r\" d=\"M17 1",
"7V36Q17 47 28 47H46\" fill=\"none\" stroke-width=\"7.5\" stroke-lineca",
"p=\"round\"/><circle class=\"i\" cx=\"17\" cy=\"16\" r=\"8.5\"/><circl",
"e class=\"i\" cx=\"48\" cy=\"47\" r=\"8.5\"/></svg>",
);
fn from_base64(text: &str) -> Vec<u8> {
let table = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut bits = 0u32;
let mut count = 0;
let mut out = Vec::new();
for c in text.bytes().filter(|&c| c != b'=') {
let v = table.iter().position(|&t| t == c).expect("a base64 digit") as u32;
bits = bits << 6 | v;
count += 6;
if count >= 8 {
count -= 8;
out.push((bits >> count) as u8);
bits &= (1 << count) - 1;
}
}
out
}
#[test]
fn the_favicon_is_the_mark_as_an_image_the_csp_admits() {
assert!(super::CSP.contains("img-src data:"), "{}", super::CSP);
let marker = "href=\"data:image/svg+xml;base64,";
let at = super::FAVICON
.find(marker)
.expect("a base64 SVG in the link");
let payload = &super::FAVICON[at + marker.len()..];
let payload = &payload[..payload.find('"').expect("the attribute closes")];
let decoded = String::from_utf8(from_base64(payload)).expect("UTF-8");
assert_eq!(decoded, FAVICON_SVG);
let rest = decoded.replace("xmlns=\"http://www.w3.org/2000/svg\"", "");
for reach in ["http", "href", "url("] {
assert!(
!rest.contains(reach),
"the icon reaches out with {reach}: {rest}"
);
}
}
#[test]
fn every_page_head_carries_the_favicon() {
let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("src")
.join("web");
let mut heads = 0;
for entry in std::fs::read_dir(&dir).unwrap() {
let path = entry.unwrap().path();
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let text = std::fs::read_to_string(&path).unwrap();
let opened = text.matches(concat!("<he", "ad>")).count();
let icons = text.matches(concat!("{FAV", "ICON}")).count();
assert_eq!(
opened,
icons,
"{} writes {opened} heads and {icons} icons",
path.display()
);
heads += opened;
}
assert!(
heads >= 4,
"only {heads} heads found: the search broke, not the pages"
);
}
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));
}
}