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