#![feature(try_blocks)] use clap::Clap; use controller::Controller; use git2::BranchType; use git2::Commit; use git2::ObjectType; use git2::Oid; use git2::Repository; use git2::Signature; use git2::Tree; use hyper::header; use hyper::http::request::Parts; use hyper::http::response::Builder; use hyper::service::{make_service_fn, service_fn}; use hyper::Method; use hyper::StatusCode; use hyper::{Body, Server}; use percent_encoding::percent_decode_str; use pulldown_cmark::html; use pulldown_cmark::Options; use pulldown_cmark::Parser; use serde::Deserialize; use sputnik::html_escape; use sputnik::hyper_body::FormError; use sputnik::mime; use sputnik::request::SputnikParts; use sputnik::response::SputnikBuilder; use std::convert::Infallible; use std::env; use std::path::Component; use std::path::Path; use std::path::PathBuf; use std::str::Utf8Error; use std::sync::Arc; use url::Url; #[cfg(unix)] use { hyperlocal::UnixServerExt, std::fs, std::fs::Permissions, std::os::unix::prelude::PermissionsExt, }; use crate::controller::MultiUserController; use crate::controller::SoloController; mod controller; mod diff; mod forms; mod get_routes; mod post_routes; pub(crate) type Response = hyper::Response; pub(crate) type Request = hyper::Request; #[derive(Clap, Debug)] #[clap(name = "gitpad")] struct Args { /// Enable mutliuser mode (requires a reverse-proxy that handles /// authentication and sets the Username header) #[clap(short)] multiuser: bool, #[clap(short, default_value = "8000")] port: u16, /// Enforce the given HTTP Origin header value to prevent CSRF attacks. #[clap(long, validator = validate_origin)] origin: Option, /// Serve via the given Unix domain socket path. #[cfg(unix)] #[clap(long)] socket: Option, } fn validate_origin(input: &str) -> Result<(), String> { let url = Url::parse(input).map_err(|e| e.to_string())?; if url.scheme() != "http" && url.scheme() != "https" { return Err("must start with http:// or https://".into()); } if url.path() != "/" { return Err("must not have a path".into()); } if input.ends_with('/') { return Err("must not end with a trailing slash".into()); } Ok(()) } #[tokio::main] async fn main() { let args = Args::parse(); let repo = Repository::open_bare(env::current_dir().unwrap()) .expect("expected current directory to be a bare Git repository"); if args.origin.is_none() { eprintln!( "[warning] Running gitpad without --origin might \ make you vulnerable to CSRF attacks." ); } if args.multiuser { serve(MultiUserController::new(&repo), args).await; } else { serve(SoloController, args).await; } } async fn serve(controller: C, args: Args) { let controller = Arc::new(controller); let args = Arc::new(args); let server_args = args.clone(); #[cfg(unix)] if let Some(socket_path) = &server_args.socket { // TODO: get rid of code duplication // we somehow need to specify the closure type or it gets too specific let service = make_service_fn(move |_| { let controller = controller.clone(); let args = args.clone(); async move { Ok::<_, hyper::Error>(service_fn(move |req| { service(controller.clone(), args.clone(), req) })) } }); let path = Path::new(&socket_path); if path.exists() { fs::remove_file(path).unwrap(); } let server = Server::bind_unix(path).unwrap(); if fs::metadata(path.parent().unwrap()) .unwrap() .permissions() .mode() & 0o001 != 0 { eprintln!("socket parent directory must not have x permission for others"); std::process::exit(1); } fs::set_permissions(path, Permissions::from_mode(0o777)) .expect("failed to set socket permissions"); println!("Listening on unix socket {}", socket_path); server.serve(service).await.expect("server error"); return; } eprint!( "[warning] Serving GitPad over a TCP socket. \ If you use a reverse-proxy for access control, \ it can be circumvented by anybody with a system account." ); #[cfg(unix)] eprint!( " Use a Unix domain socket (with --socket) to restrict \ access based on the socket parent directory permissions." ); eprintln!(); let service = make_service_fn(move |_| { let controller = controller.clone(); let args = args.clone(); async move { Ok::<_, hyper::Error>(service_fn(move |req| { service(controller.clone(), args.clone(), req) })) } }); let addr = ([127, 0, 0, 1], server_args.port).into(); let server = Server::bind(&addr).serve(service); println!("Listening on http://{}", addr); server.await.expect("server error"); } pub enum Error { /// A 400 bad request error. BadRequest(String), /// A 401 unauthorized error. Unauthorized(String, Context), /// A 403 forbidden error. Forbidden(String), /// A 404 not found error. NotFound(String), /// A 500 internal server error. Internal(String), /// A 302 redirect to the given path. Redirect(String), // TODO: use Redirect instead /// Missing trailing slash. MissingTrailingSlash(Parts), } impl From for Error { fn from(_: Utf8Error) -> Self { Self::BadRequest("invalid UTF-8".into()) } } async fn service( controller: Arc, args: Arc, request: Request, ) -> Result { let (parts, body) = request.into_parts(); let mut resp = build_response(args, &*controller, parts, body) .await .unwrap_or_else(|err| { if let Some(resp) = controller.before_return_error(&err) { return resp; } let (status, message) = match err { Error::BadRequest(msg) => (400, msg), Error::Unauthorized(msg, _ctx) => (401, msg), Error::Forbidden(msg) => (403, msg), Error::NotFound(msg) => (404, msg), Error::Internal(msg) => (500, msg), Error::MissingTrailingSlash(parts) => { return Builder::new() .status(StatusCode::FOUND) .header("location", format!("{}/", parts.uri.path())) .body("redirecting".into()) .unwrap(); } Error::Redirect(target) => { return Builder::new() .status(StatusCode::FOUND) .header("location", target) .body("redirecting".into()) .unwrap(); } }; // TODO: use Page Builder::new() .status(status) .header("content-type", "text/html") .body(message.into()) .unwrap() }); // we rely on CSP to thwart XSS attacks, all modern browsers support it resp.headers_mut().insert( header::CONTENT_SECURITY_POLICY, format!( "child-src 'none'; script-src 'sha256-{}'; style-src 'sha256-{}'", include_str!("static/edit_script.js.sha256"), include_str!("static/style.css.sha256"), ) .parse() .unwrap(), ); Ok(resp) } pub struct Page<'a> { title: String, header: Option, body: String, controller: &'a dyn Controller, parts: &'a Parts, } impl From> for Response { fn from(page: Page) -> Self { Builder::new() .content_type(mime::TEXT_HTML) .body(page.render().into()) .unwrap() } } const CSS: &str = include_str!("static/style.css"); impl Page<'_> { fn render(&self) -> String { format!( "\ \ \ \ {}\ \ \ \ {}\ ", html_escape(&self.title), CSS, self.header.as_deref().unwrap_or_default(), self.controller .user_info_html(self.parts) .map(|h| format!("", h)) .unwrap_or_default(), self.body, ) } } #[derive(Deserialize)] struct ActionParam { #[serde(default = "default_action")] action: String, } fn default_action() -> String { "view".into() } impl From for Error { fn from(e: git2::Error) -> Self { eprintln!("git error: {}", e); Self::Internal("something went wrong with git".into()) } } /// Builds a URL path from a given Git revision and filepath. fn build_url_path(rev: &Branch, path: &str) -> String { format!("/~{}/{}", rev.0, path) } #[derive(Eq, PartialEq, Hash, Clone)] pub struct Branch(String); impl Branch { fn rev_str(&self) -> String { format!("refs/heads/{}", self.0) } } async fn build_response( args: Arc, controller: &C, parts: Parts, body: Body, ) -> Result { controller.before_route(&parts)?; let unsanitized_path = percent_decode_str(parts.uri.path()) .decode_utf8() .map_err(|_| Error::BadRequest("failed to percent-decode path as UTF-8".into()))? .into_owned(); let repo = Repository::open_bare(env::current_dir().unwrap()).unwrap(); if parts.uri.path() == "/" { // TODO: add domain name to title? let mut page = Page { title: "GitPad".into(), controller, parts: &parts, body: String::new(), header: None, }; let branches: Vec<_> = repo.branches(Some(BranchType::Local))?.collect(); page.body.push_str("This GitPad instance has "); if branches.is_empty() { page.body.push_str("no branches yet."); if !args.multiuser { page.body.push_str("

Start by creating for example /~main/todo.md.

"); } } else { page.body.push_str("the following branches:"); page.body.push_str("
    "); for (branch, _) in repo.branches(Some(BranchType::Local))?.flatten() { page.body.push_str(&format!( "
  • ~{0}
  • ", html_escape(branch.name()?.unwrap()) )); } page.body.push_str("
"); } return Ok(page.into()); } let mut iter = unsanitized_path.splitn(3, '/'); iter.next(); let rev = iter.next().unwrap(); if !rev.starts_with('~') { return Err(Error::NotFound( "branch name must be prefixed a tilde (~)".into(), )); } let rev = &rev[1..]; if rev.trim().is_empty() { return Err(Error::NotFound("invalid branch name".into())); } let rev = Branch(rev.to_owned()); let unsanitized_path = match iter.next() { Some(value) => value, None => return Err(Error::MissingTrailingSlash(parts)), }; let mut comps = Vec::new(); // prevent directory traversal attacks for comp in Path::new(&*unsanitized_path).components() { match comp { Component::Normal(name) => comps.push(name), Component::ParentDir => { return Err(Error::Forbidden("path traversal is forbidden".into())) } _ => {} } } let params: ActionParam = parts.query::().unwrap(); let url_path: PathBuf = comps.iter().collect(); let ctx = Context { repo, path: url_path, branch: rev, parts, }; if !controller.may_read_path(&ctx) { return Err(Error::Unauthorized( "you are not authorized to view this file".into(), ctx, )); } if ctx.parts.method == Method::POST { return post_routes::build_response(&args, ¶ms, controller, ctx, body).await; } let mut tree = ctx .repo .revparse_single(&ctx.branch.rev_str()) .map(|r| r.into_commit().unwrap().tree().unwrap()); if ctx.path.components().next().is_some() { let entr = match tree.and_then(|t| t.get_path(&ctx.path)) { Ok(entr) => entr, Err(_) => { if unsanitized_path.ends_with('/') { return Err(Error::NotFound("directory not found".into())); } if controller.may_write_path(&ctx) { if params.action == "edit" { return Ok(forms::edit_text_form( &forms::EditForm::default(), None, controller, &ctx, ) .into()); } else if params.action == "upload" { return Ok(forms::upload_form(false, controller, &ctx).into()); } else { return Err(Error::NotFound( "file not found, but you can write it or upload it".into(), )); } } else { return Err(Error::NotFound("file not found".into())); } } }; if entr.kind().unwrap() == ObjectType::Blob { if unsanitized_path.ends_with('/') { return Ok(Builder::new() .status(StatusCode::FOUND) .header( "location", build_url_path(&ctx.branch, unsanitized_path.trim_end_matches('/')), ) .body("redirecting".into()) .unwrap()); } return get_routes::view_blob(entr, params, controller, ctx); } tree = ctx.repo.find_tree(entr.id()); if !unsanitized_path.ends_with('/') { return Err(Error::MissingTrailingSlash(ctx.parts)); } } get_routes::view_tree(tree, controller, &ctx) } fn render_link(name: &str, label: &str, active_action: &str) -> String { format!( " {}", if name == active_action { "class=active".into() } else { format!("href=?action={}", name) }, if name != label { format!(" title='{}'", name) } else { "".into() }, label ) } fn action_links(active_action: &str, controller: &C, ctx: &Context) -> String { let mut out = String::new(); out.push_str("
"); out.push_str("ls"); out.push_str(&render_link("view", "view", active_action)); if controller.may_write_path(ctx) { out.push_str(&render_link("edit", "edit", active_action)); } out.push_str(&render_link("log", "log", active_action)); out.push_str(&render_link("raw", "raw", active_action)); if controller.may_move_path(ctx) { out.push_str(&render_link("move", "mv", active_action)); out.push_str(&render_link("remove", "rm", active_action)); } out.push_str("
"); out } impl From for Error { fn from(e: FormError) -> Self { Self::BadRequest(e.to_string()) } } pub struct Context { repo: Repository, parts: Parts, branch: Branch, path: PathBuf, } impl Context { fn branch_head(&self) -> Result { self.repo .revparse_single(&self.branch.rev_str()) .map_err(|_| Error::NotFound("branch not found".into()))? .into_commit() .map_err(|_| Error::NotFound("branch not found".into())) } fn commit( &self, signature: &Signature, msg: &str, tree: &Tree, parent_commits: &[&Commit], ) -> Result { self.repo.commit( Some(&self.branch.rev_str()), signature, signature, msg, tree, parent_commits, ) } } fn render_markdown(input: &str) -> String { let parser = Parser::new_ext(input, Options::all()); let mut out = String::new(); out.push_str("
"); html::push_html(&mut out, parser); out.push_str("
"); out } fn get_renderer(path: &Path) -> Option String> { match path.extension().map(|e| e.to_str().unwrap()) { Some("md") => Some(render_markdown), _ => None, } }