#![deny(warnings)] use std::convert::Infallible; use std::fs; use std::net::SocketAddr; use std::pin::Pin; use std::process::{Command, Stdio}; use std::sync::Arc; use bytes::Bytes; use clap::{Arg, ArgMatches, Command as ClapCommand}; use http_body_util::{BodyExt, Full}; use hyper::body::Incoming as IncomingBody; use hyper::http::response::Builder; use hyper::server::conn::http1; use hyper::service::Service; use hyper::{Method, Request, Response, StatusCode}; use hyper_util::rt::TokioIo; use tokio::net::TcpListener; use tokio::task; use core::hash::{Hash, Hasher}; use std::collections::hash_map::DefaultHasher; use std::future::Future; struct Opts { port: u16, file: String, commit: bool, } fn empty() -> Full<Bytes> { Full::new(Bytes::new()) } fn from<T: Into<Bytes>>(data: T) -> Full<Bytes> { Full::new(data.into()) } fn no_changes(file: &str) -> bool { let output = Command::new("git") .args(&["status", "--porcelain", file]) .output() .expect("Failed to execute git"); output.stdout.is_empty() } fn commit(file: &str) { if no_changes(file) { return; } let commit = Command::new("git") .args(&[ "commit", file, "-m", "Commit after PUT", "--author", "DAV Daemon <dav@example.org>", ]) .stdout(Stdio::null()) .status() .expect("Failed to execute git"); if !commit.success() { panic!("Failed to commit: {}", file) } } fn check_if_tracked(file: &str) { let check = Command::new("git") .args(&["ls-files", "--error-unmatch", file]) .stdout(Stdio::null()) .status() .expect("Failed to execute git"); if !check.success() { panic!("File not tracked in git: {}", file) } } fn etag(bytes: &[u8]) -> String { let mut hasher = DefaultHasher::new(); bytes.hash(&mut hasher); hasher.finish().to_string() } fn respond(status: StatusCode) -> Builder { Response::builder().status(status) } fn favicon() -> Response<Full<Bytes>> { let file = "favicon.ico"; match fs::read(file) { Ok(bytes) => respond(StatusCode::OK) .header("etag", etag(&bytes)) .body(from(bytes)) .unwrap(), Err(_) => respond(StatusCode::NOT_FOUND).body(empty()).unwrap(), } } fn get(opts: &Opts) -> Response<Full<Bytes>> { let bytes = fs::read(&opts.file).expect("the file is gone"); respond(StatusCode::OK) .header("content-type", "text/html") .header("etag", etag(&bytes)) .body(from(bytes)) .unwrap() } fn head(opts: &Opts) -> Response<Full<Bytes>> { let mut result = get(opts); *result.body_mut() = empty(); *result.status_mut() = StatusCode::NO_CONTENT; result } fn save(opts: &Opts, bytes: &[u8]) { fs::write(&opts.file, bytes).expect("cannot write to the file"); if opts.commit { commit(&opts.file) } } async fn put(opts: &Opts, req: Request<IncomingBody>) -> Response<Full<Bytes>> { let client_etag = req .headers() .get("if-match") .map_or("", |etag| etag.to_str().unwrap()); let our_etag = etag(&fs::read(&opts.file).unwrap()); if client_etag != our_etag { respond(StatusCode::PRECONDITION_FAILED) .body(empty()) .unwrap() } else { let bytes = req.collect().await.expect("put body").to_bytes(); save(opts, &bytes); respond(StatusCode::NO_CONTENT).body(empty()).unwrap() } } fn bad_request() -> Response<Full<Bytes>> { respond(StatusCode::BAD_REQUEST) .header("content-type", "text/plain") .body(from("bad request\n")) .unwrap() } fn options() -> Response<Full<Bytes>> { respond(StatusCode::OK) .header("dav", "1") .body(empty()) .unwrap() } fn log_page(opts: &Opts) -> Response<Full<Bytes>> { let output = Command::new("git") .args(&[ "log", "--pretty=format:<li><a href='show/%H'>%h on %aD</a></li>", "--", &opts.file, ]) .output() .expect("Failed to execute git log"); let list = String::from_utf8_lossy(&output.stdout); let html = format!( "<html>\ <head><title>Git Log for {file}</title></head>\ <body>\ <h1>Git Log for {file}</h1>\ <ul>{list}</ul>\ </body>\ </html>", file = opts.file, list = list ); respond(StatusCode::OK) .header("content-type", "text/html") .body(from(html)) .unwrap() } fn get_show_commit(opts: &Opts, commit: &str) -> Response<Full<Bytes>> { let show_arg = format!("{}:{}", commit, &opts.file); let output = Command::new("git") .args(&["show", &show_arg]) .output() .expect("Failed to execute git show"); if output.status.success() { let script = r#"<script> document.body.setAttribute('data-historical', 'true'); </script> <style> body[data-historical]::before { content: "Historical Snapshot"; display: block; background-color: #ffdd57; padding: 8px; text-align: center; font-weight: bold; } body[data-historical]::after { content: ""; position: fixed; top: 0; left: 0; width: 1em; height: 100%; background-color: #ffdd57; } </style>"#; let mut buf = bytes::BytesMut::from(&output.stdout[..]); buf.extend(script.as_bytes()); respond(StatusCode::OK) .header("content-type", "text/html") .body(from(buf.freeze())) .unwrap() } else { respond(StatusCode::NOT_FOUND).body(empty()).unwrap() } } fn valid_show_path(path: &str) -> bool { if let Some(commit) = path.strip_prefix("/show/") { commit.len() == 40 && commit.chars().all(|c| c.is_digit(16)) } else { false } } async fn handle(opts: Arc<Opts>, req: Request<IncomingBody>) -> Response<Full<Bytes>> { match (req.method(), req.uri().path()) { (&Method::GET, "/") => get(&opts), (&Method::GET, "/favicon.ico") => favicon(), (&Method::GET, "/log") => log_page(&opts), (&Method::GET, p) if valid_show_path(p) => { // GET /show/{commit} get_show_commit(&opts, &p["/show/".len()..]) } (&Method::HEAD, "/") => head(&opts), (&Method::OPTIONS, "/") => options(), (&Method::PUT, "/") => put(&opts, req).await, _ => bad_request(), } } fn commit_opt(file: &str, matches: &ArgMatches) -> bool { if matches.is_present("git") { check_if_tracked(file); true } else { false } } fn opts() -> Opts { let matches = ClapCommand::new("dav") .arg( Arg::new("port") .help("TCP port to listen on") .short('p') .default_value("53502"), ) .arg( Arg::new("git") .help("Periodically commit changes to git") .takes_value(false) .short('g'), ) .arg( Arg::new("file") .help("File to serve") .short('f') .default_value("tiddlywiki.html"), ) .get_matches(); let port = matches.value_of("port").unwrap().parse().unwrap(); let file = matches.value_of("file").unwrap().to_owned(); if fs::metadata(&file).is_err() { panic!("File doesn't exist: {}", &file); } let commit = commit_opt(&file, &matches); Opts { file, port, commit } } #[derive(Clone)] struct MyService { opts: Arc<Opts>, } impl Service<Request<IncomingBody>> for MyService { type Response = Response<Full<Bytes>>; type Error = Infallible; type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>; fn call(&self, req: Request<IncomingBody>) -> Self::Future { let opts = self.opts.clone(); Box::pin(async move { Ok(handle(opts, req).await) }) } } #[tokio::main] pub async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> { let opts = Arc::new(opts()); let addr = SocketAddr::from(([127, 0, 0, 1], opts.port)); let listener = TcpListener::bind(addr).await?; println!("Listening on http://{}", addr); loop { let (stream, _) = listener.accept().await?; let io = TokioIo::new(stream); let service = MyService { opts: opts.clone() }; task::spawn(async move { if let Err(err) = http1::Builder::new().serve_connection(io, service).await { eprintln!("Error serving connection: {:?}", err); } }); } }