breb 0.5.0

the blog/reblog library and command-line tool
Documentation
mod build_dir;
mod escape;

use std::{
	io, mem, net::{Ipv4Addr, Ipv6Addr, SocketAddr}, path::PathBuf, sync::{Arc, RwLock, mpsc}, thread::{self, JoinHandle}, time::{Duration, Instant},
};

use rouille::{Request, Response, Server};

use crate::{Mime, live::build_dir::Current, raw, serve::Url};

trait RouilleHandler: Send + Sync + 'static + Fn(&Request) -> Response {}
impl<T: Send + Sync + 'static + Fn(&Request) -> Response> RouilleHandler for T {}

/// serve a [`raw::Config`], fresh from the disk
///
/// this will almost always live behind an `Arc`, so expect to be `.clone()`ing a lot!
///
/// this autorefreshes by default.
/// that works by hosting a new url somewhere under `/.breb-sys/` (it's long and random to avoid collisions)
/// with a version number derived from scanning and hashing the metadata of the input directory.
/// a bit of javascript injected into any html page to ping that, check if the value changes, and refresh.
pub struct LiveServer {
	dir: PathBuf,
	cfg: ProvidedConfig,
	autorefresh: bool,
}

#[derive(Clone)]
enum ProvidedConfig {
	Live(PathBuf),
	Static(raw::Config),
}

impl LiveServer {
	/// serve a blog in the given directory with the given config
	pub fn live_config(cfg: impl Into<PathBuf>, dir: impl Into<PathBuf>) -> Self {
		Self {
			dir: dir.into(),
			cfg: ProvidedConfig::Live(cfg.into()),
			autorefresh: true,
		}
	}

	/// serve a blog in the given directory with a *static* config
	pub fn static_config(cfg: raw::Config, dir: impl Into<PathBuf>) -> Self {
		Self {
			dir: dir.into(),
			cfg: ProvidedConfig::Static(cfg),
			autorefresh: true,
		}
	}

	/// disable autorefresh
	pub fn without_refresh(mut self) -> Self {
		self.autorefresh = false;
		self
	}

	fn server(
		&self,
		port: impl Into<Option<u16>>,
	) -> io::Result<(Server<impl RouilleHandler + 'static>, Option<JoinHandle<()>>)> {
		let autorefresh = self.autorefresh;
		let (data, watcher) = if autorefresh {
			let (current, watcher) = build_dir::watch(self.cfg.clone(), self.dir.clone());
			(current, Some(watcher))
		} else {
			let current = build_dir::once(self.cfg.clone(), self.dir.clone());
			(Arc::new(RwLock::new(current)), None)
		};
		let port = port.into().unwrap_or(0);
		let data2 = data.clone();
		let res = rouille::Server::new(
			[(Ipv4Addr::LOCALHOST, port).into(), (Ipv6Addr::LOCALHOST, port).into()].as_ref(),
			move |req| serve(&data, autorefresh, req),
		);
		match res {
			Ok(server) => Ok((server.pool_size(1), watcher)),
			Err(e) => {
				mem::drop(data2);
				if let Some(w) = watcher {
					w.join().unwrap();
				}
				Err(io::Error::other(format!("failed to make server: {e}")))
			}
		}
	}

	/// serve forever on the current thread
	pub fn listen(&self, port: impl Into<Option<u16>>) -> io::Result<()> {
		let (server, worker) = self.server(port)?;
		server.run();
		if let Some(w) = worker {
			w.join().unwrap();
		}
		Ok(())
	}

	/// serve until stopped on a background thread
	pub fn listen_background(&self, port: impl Into<Option<u16>>) -> io::Result<BackgroundServer> {
		let (server, watcher) = self.server(port)?;
		let address = server.server_addr();
		// can't just use `.stoppable`, because of the live refresh.
		// it sends more than 1 request a second,
		// and `.stoppable` doesn't actually stop until it's been longer than that between requests.
		// this appears to be a bug; the method they call is documented as
		// "blocking no longer than" the provided time,
		// but it actually blocks no longer than that *per request*.
		let (stop_send, stop_recv) = mpsc::sync_channel(0);
		let handle = thread::spawn(move || {
			while stop_recv.try_recv() == Err(mpsc::TryRecvError::Empty) {
				server.poll_timeout(Duration::from_millis(100));
			}
		});
		Ok(BackgroundServer {
			address,
			stop: stop_send,
			handle,
			watcher,
		})
	}
}

/// a [`LiveServer`] running in the background
pub struct BackgroundServer {
	pub address: SocketAddr,
	stop: mpsc::SyncSender<()>,
	handle: JoinHandle<()>,
	watcher: Option<JoinHandle<()>>,
}

impl BackgroundServer {
	pub fn stop(self) {
		// .send fails if the other end is gone, which is fine
		let _ = self.stop.send(());
		// .join can be called repeatedly without issue
		// (we want to propagate any panicks that happened, though)
		self.handle.join().unwrap();
		// ditto for the watcher thread, if there is one
		if let Some(w) = self.watcher {
			w.join().unwrap();
		}
	}
}

const UNIQ: &str = "/.breb-sys/SzRPbelaEjcYBzDhwBxb4A/";

fn serve(watched: &RwLock<Current>, autorefresh: bool, req: &Request) -> Response {
	const SERVER: &str = concat!("blog-reblog/", env!("CARGO_PKG_VERSION"));

	let start = Instant::now();

	let lock = watched.read().unwrap();
	let data = &*lock;

	let resp = if let Some(suffix) = req.raw_url().strip_prefix(UNIQ) {
		let name = match suffix.split_once('?') {
			Some((l, _)) => l,
			None => suffix,
		};
		serve_internal(data, autorefresh, name)
	} else {
		let resp = serve_blog(data, autorefresh, req);
		println!(
			"{}: {} {:?} -({}us)-> {}",
			req.remote_addr(),
			req.method(),
			req.url(),
			start.elapsed().as_micros(),
			resp.status_code,
		);
		resp
	};
	// there might be a way to do this without having to render the whole body,
	// but Content-Length makes it complex,
	// and response times in release are already ~70us, so… w/e
	resp.with_unique_header("Server", SERVER).with_etag(req, format!("{:x}", data.version))
}

fn serve_blog(data: &Current, autorefresh: bool, req: &Request) -> Response {
	if req.method() != "GET" && req.method() != "HEAD" {
		return Response::text("only GET (and HEAD) allowed").with_status_code(405);
	}

	let raw_url = req.url();
	let blog = match &data.blog {
		Ok(b) => b,
		Err(e) => return error(&raw_url, e),
	};

	let mut url = PathBuf::from(&raw_url);
	if let Ok(rel) = url.strip_prefix("/") {
		url = rel.to_owned();
	}

	let Some(servable) = blog.servable(Url(url.clone())) else {
		return not_found(&raw_url);
	};
	if servable.content_type() == &Mime::HTML && !raw_url.ends_with("/") {
		// bit of a bodge, but we need to redirect e.g.
		// /about -> /about/ for consistency with nginx
		// the path parsing is the same for either
		let mut new_url = raw_url;
		new_url.push('/');
		return Response::redirect_302(new_url);
	}

	let mut body = vec![];
	if let Err(e) = servable.render(&mut body) {
		return error(&raw_url, &e);
	}
	if autorefresh && servable.content_type() == &Mime::HTML {
		use io::Write;
		// just straight up appending the `<script>` tag technically produces broken html,
		// but browsers are friendly and helpful and let us get away with it
		write!(
			&mut body,
			r##"<script data-uniq={:?} data-version="{:x}">{}</script>"##,
			UNIQ,
			data.version,
			include_str!("refresher.js"),
		)
		.expect("write to vec is infallible");
	}
	Response::from_data(servable.content_type(), body)
}

fn serve_internal(data: &Current, autorefresh: bool, url: &str) -> Response {
	match url {
		"version" if autorefresh => Response::text(format!("{:x}", data.version)),
		_ => Response::text("that page doesn't exist c:").with_status_code(404),
	}
}

fn error(url: &str, e: &io::Error) -> Response {
	Response::html(escape::render!("500.html", url = url, error = e)).with_status_code(500)
}

fn not_found(url: &str) -> Response {
	Response::html(escape::render!("404.html", url = url)).with_status_code(404)
}