#![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() } 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::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); } }); } }