#![feature(try_blocks)] use chrono::NaiveDateTime; use clap::Clap; use controller::Controller; use difference::Changeset; use difference::Difference; use git2::build::TreeUpdateBuilder; use git2::BranchType; use git2::Commit; use git2::FileMode; use git2::ObjectType; use git2::Oid; use git2::Repository; use git2::Signature; use git2::Tree; use git2::TreeEntry; 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 multer::Multipart; 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::hyper_body::SputnikBody; use sputnik::mime; use sputnik::request::SputnikParts; use sputnik::response::SputnikBuilder; use std::cmp; use std::convert::Infallible; use std::env; use std::fmt::Write as FmtWrite; use std::path::Component; use std::path::Path; use std::path::PathBuf; use std::str::from_utf8; 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; 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 { if let Some(ref enforced_origin) = args.origin { if ctx .parts .headers .get(header::ORIGIN) .filter(|h| h.as_bytes() == enforced_origin.as_bytes()) .is_none() { return Err(Error::BadRequest(format!( "POST requests must be sent with the header Origin: {}", enforced_origin ))); } } match params.action.as_ref() { "edit" => return update_blob(body, controller, ctx).await, "upload" => return upload_blob(body, controller, ctx).await, "move" => return move_entry(body, controller, ctx).await, "remove" => return remove_entry(body, controller, ctx).await, "diff" => return diff_blob(body, controller, ctx).await, "preview" => return preview_edit(body, controller, ctx).await, _ => { return Err(Error::BadRequest("unknown POST action".into())); } } } 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( edit_text_form(&EditForm::default(), None, controller, &ctx).into() ); } else if params.action == "upload" { return Ok(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 view_blob(entr, params, controller, ctx); } tree = ctx.repo.find_tree(entr.id()); if !unsanitized_path.ends_with('/') { return Err(Error::MissingTrailingSlash(ctx.parts)); } } 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()) } } async fn upload_blob( body: Body, controller: &C, mut ctx: Context, ) -> Result { if !controller.may_write_path(&ctx) { return Err(Error::Unauthorized( "you are not authorized to edit this file".into(), ctx, )); } // Extract the `multipart/form-data` boundary from the headers. let boundary = ctx .parts .headers .get(header::CONTENT_TYPE) .and_then(|ct| ct.to_str().ok()) .and_then(|ct| multer::parse_boundary(ct).ok()) .ok_or_else(|| Error::BadRequest("expected multipart/form-data".into()))?; let mut multipart = Multipart::new(Box::new(body), boundary); while let Some(field) = multipart .next_field() .await .map_err(|_| Error::BadRequest("failed to parse multipart".into()))? { if field.name() == Some("file") { // TODO: make commit message customizable commit_file_update(&field.bytes().await.unwrap(), None, controller, &ctx)?; controller.after_write(&mut ctx); return Ok(Builder::new() .status(StatusCode::FOUND) .header("location", ctx.parts.uri.path()) .body("redirecting".into()) .unwrap()); } } Err(Error::BadRequest( "expected file upload named 'file'".into(), )) } fn commit_file_update( data: &[u8], msg: Option, controller: &C, ctx: &Context, ) -> Result<(), Error> { let blob_id = ctx.repo.blob(data)?; let mut builder = TreeUpdateBuilder::new(); builder.upsert(ctx.path.to_str().unwrap(), blob_id, FileMode::Blob); let (parent_tree, parent_commits) = if let Ok(commit) = ctx.branch_head() { let parent_tree = commit.tree()?; if parent_tree.get_path(&ctx.path).ok().map(|e| e.id()) == Some(blob_id) { // nothing changed, don't create an empty commit return Err(Error::Redirect(ctx.parts.uri.path().to_string())); } (parent_tree, vec![commit]) } else { // the empty tree exists even in empty bare repositories // we could also hard-code its hash here but magic strings are uncool let empty_tree = ctx.repo.find_tree(ctx.repo.index()?.write_tree()?)?; (empty_tree, vec![]) }; let new_tree_id = builder.create_updated(&ctx.repo, &parent_tree)?; let signature = controller.signature(&ctx.repo, &ctx.parts)?; ctx.commit( &signature, &msg.filter(|m| !m.trim().is_empty()).unwrap_or_else(|| { format!( "{} {}", if parent_tree.get_path(&ctx.path).is_ok() { "edit" } else { "create" }, ctx.path.to_str().unwrap() ) }), &ctx.repo.find_tree(new_tree_id)?, &parent_commits.iter().collect::>()[..], )?; Ok(()) } async fn preview_edit( body: Body, controller: &C, ctx: Context, ) -> Result { let form: EditForm = body.into_form().await?; let new_text = form.text.replace("\r\n", "\n"); let mut page = edit_text_form(&form, None, controller, &ctx); page.body .push_str(&(get_renderer(&ctx.path).unwrap()(&new_text))); Ok(page.into()) } async fn diff_blob( body: Body, controller: &C, ctx: Context, ) -> Result { if !controller.may_write_path(&ctx) { return Err(Error::Unauthorized( "you are not authorized to edit this file".into(), ctx, )); } let form: EditForm = body.into_form().await?; let new_text = form.text.replace("\r\n", "\n"); let entr = ctx.branch_head()?.tree().unwrap().get_path(&ctx.path)?; let blob = ctx.repo.find_blob(entr.id()).unwrap(); let old_text = from_utf8(blob.content())?; let mut page = edit_text_form(&form, None, controller, &ctx); page.body.push_str(&diff(old_text, &new_text)); Ok(page.into()) } fn diff(first: &str, second: &str) -> String { if first == second { return "(no changes)".into(); } let Changeset { diffs, .. } = Changeset::new(&first, &second, "\n"); let mut output = String::new(); output.push_str("
");

    for i in 0..diffs.len() {
        match diffs[i] {
            Difference::Same(ref text) => {
                let text = html_escape(text);
                let lines: Vec<_> = text.split('\n').collect();
                if i == 0 {
                    output.push_str(&lines[lines.len().saturating_sub(3)..].join("\n"));
                } else if i == diffs.len() - 1 {
                    output.push_str(&lines[..cmp::min(3, lines.len())].join("\n"));
                } else {
                    output.push_str(&text);
                }
            }
            Difference::Add(ref text) => {
                output.push_str("
"); if i == 0 { output.push_str(&html_escape(text).replace("\n", "
")); } else { match diffs.get(i - 1) { Some(Difference::Rem(ref rem)) => { word_diff(&mut output, rem, text, "ins"); } _ => { output.push_str(&html_escape(text).replace("\n", "
")); } } } output.push_str("\n
"); } Difference::Rem(ref text) => { output.push_str("
"); match diffs.get(i + 1) { Some(Difference::Add(ref add)) => { word_diff(&mut output, add, text, "del"); } _ => { output.push_str(&html_escape(text).replace("\n", "
")); } } output.push_str("\n
"); } } } output.push_str("
"); output } fn word_diff(out: &mut String, text1: &str, text2: &str, tagname: &str) { let Changeset { diffs, .. } = Changeset::new(text1, text2, " "); for c in diffs { match c { Difference::Same(ref z) => { out.push_str(&html_escape(z).replace("\n", "
")); out.push(' '); } Difference::Add(ref z) => { write!( out, "<{0}>{1} ", tagname, html_escape(z).replace("\n", "
") ) .expect("write error"); } _ => {} } } } #[derive(Deserialize, Default)] struct EditForm { text: String, msg: Option, oid: Option, } async fn update_blob( body: Body, controller: &C, mut ctx: Context, ) -> Result { if !controller.may_write_path(&ctx) { return Err(Error::Unauthorized( "you are not authorized to edit this file".into(), ctx, )); } let mut data: EditForm = body.into_form().await?; if let Ok(commit) = ctx.branch_head() { // edit conflict detection let latest_oid = commit .tree() .unwrap() .get_path(&ctx.path) .ok() .map(|e| e.id().to_string()); if data.oid != latest_oid { data.oid = latest_oid; return Ok(edit_text_form(&data, Some( if data.oid.is_some() { "this file has been edited in the meantime, if you save you will overwrite the changes made since" } else { "this file has been deleted in the meantime, if you save you will re-create it" } ), controller, &ctx).into()); } } // normalize newlines as per HTML spec let text = data.text.replace("\r\n", "\n"); if let Err(error) = controller.before_write(&text, &mut ctx) { return Ok(edit_text_form(&data, Some(&error), controller, &ctx).into()); } commit_file_update(text.as_bytes(), data.msg, controller, &ctx)?; controller.after_write(&mut ctx); return Ok(Builder::new() .status(StatusCode::FOUND) .header("location", ctx.parts.uri.path()) .body("redirecting".into()) .unwrap()); } #[derive(Deserialize)] struct MoveForm { dest: String, msg: Option, } async fn move_entry( body: Body, controller: &C, ctx: Context, ) -> Result { if !controller.may_move_path(&ctx) { return Err(Error::Unauthorized( "you are not authorized to move this file".into(), ctx, )); } let mut data: MoveForm = body.into_form().await?; let filename = ctx.path.file_name().unwrap().to_str().unwrap(); if ctx.path == Path::new(&data.dest) { return move_form( filename, &data, Some("can not move entry to itself"), controller, &ctx, ); } let parent_commit = ctx.branch_head()?; let parent_tree = parent_commit.tree().unwrap(); if parent_tree.get_path(Path::new(&data.dest)).is_ok() { return move_form( filename, &data, Some("destination already exists"), controller, &ctx, ); } let mut builder = TreeUpdateBuilder::new(); let entr = parent_tree.get_path(&ctx.path)?; builder.remove(&ctx.path); builder.upsert( &data.dest, entr.id(), match entr.filemode() { 0o000000 => FileMode::Unreadable, 0o040000 => FileMode::Tree, 0o100644 => FileMode::Blob, 0o100755 => FileMode::BlobExecutable, 0o120000 => FileMode::Link, _ => { panic!("unexpected mode") } }, ); let new_tree_id = builder.create_updated(&ctx.repo, &parent_commit.tree()?)?; ctx.commit( &controller.signature(&ctx.repo, &ctx.parts)?, &data .msg .take() .filter(|m| !m.trim().is_empty()) .unwrap_or_else(|| format!("move {} to {}", ctx.path.to_str().unwrap(), data.dest)), &ctx.repo.find_tree(new_tree_id)?, &[&parent_commit], )?; Ok(Builder::new() .status(StatusCode::FOUND) .header("location", build_url_path(&ctx.branch, &data.dest)) .body("redirecting".into()) .unwrap()) } #[derive(Deserialize)] struct RemoveForm { msg: Option, } async fn remove_entry( body: Body, controller: &C, ctx: Context, ) -> Result { if !controller.may_move_path(&ctx) { return Err(Error::Unauthorized( "you are not authorized to remove this file".into(), ctx, )); } let data: RemoveForm = body.into_form().await?; let mut builder = TreeUpdateBuilder::new(); builder.remove(&ctx.path); let parent_commit = ctx.branch_head()?; let new_tree_id = builder.create_updated(&ctx.repo, &parent_commit.tree()?)?; ctx.commit( &controller.signature(&ctx.repo, &ctx.parts)?, &data .msg .filter(|m| !m.trim().is_empty()) .unwrap_or_else(|| format!("remove {}", ctx.path.to_str().unwrap())), &ctx.repo.find_tree(new_tree_id)?, &[&parent_commit], )?; Ok(Builder::new() .status(StatusCode::FOUND) .header( "location", build_url_path(&ctx.branch, ctx.path.parent().unwrap().to_str().unwrap()), ) .body("redirecting".into()) .unwrap()) } fn render_error(message: &str) -> String { format!("
error: {}
", html_escape(message)) } 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 upload_form<'a, C: Controller>( file_exists: bool, controller: &'a C, ctx: &'a Context, ) -> Page<'a> { let filename = ctx.path.file_name().unwrap().to_str().unwrap(); Page { title: format!("Uploading {}", filename), // TODO: add input for commit message body: "
\ \ \
" .into(), header: file_exists.then(|| action_links("edit", controller, &ctx)), controller, parts: &ctx.parts, } } 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, } } fn edit_text_form<'a, C: Controller>( data: &EditForm, error: Option<&str>, controller: &'a C, ctx: &'a Context, ) -> Page<'a> { let mut page = Page { title: format!( "{} {}", if data.oid.is_some() { "Editing" } else { "Creating" }, ctx.path.file_name().unwrap().to_str().unwrap() ), header: data .oid .is_some() .then(|| action_links("edit", controller, ctx)), body: String::new(), controller, parts: &ctx.parts, }; if let Some(access_info_html) = controller.access_info_html(&ctx) { page.body.push_str(&access_info_html); } if let Some(hint_html) = controller.edit_hint_html(ctx) { page.body .push_str(&format!("
{}
", hint_html)); } if let Some(error) = error { page.body.push_str(&render_error(error)); } page.body.push_str(&format!( "
\ ", html_escape(&data.text) )); page.body .push_str("
"); if let Some(oid) = &data.oid { page.body.push_str(&format!( " ", oid )); } if get_renderer(&ctx.path).is_some() { page.body .push_str(" ") } page.body.push_str(&format!( "
", html_escape(data.msg.as_deref().unwrap_or_default()) )); page.body.push_str(&format!( "", include_str!("static/edit_script.js") )); page } fn move_form( filename: &str, data: &MoveForm, error: Option<&str>, controller: &C, ctx: &Context, ) -> Result { let mut page = Page { title: format!("Move {}", filename), controller, parts: &ctx.parts, body: String::new(), header: Some(action_links("move", controller, ctx)), }; if let Some(error) = error { page.body.push_str(&render_error(error)); } page.body.push_str(&format!( "
", html_escape(&data.dest), data.msg.as_ref().map(html_escape).unwrap_or_default(), )); Ok(page.into()) } #[derive(Deserialize)] struct LogParam { commit: Option, } fn view_blob( entr: TreeEntry, params: ActionParam, controller: &C, ctx: Context, ) -> Result { let filename = ctx.path.file_name().unwrap().to_str().unwrap(); match params.action.as_ref() { "view" => { let mut page = Page { title: filename.to_string(), body: String::new(), header: Some(action_links(¶ms.action, controller, &ctx)), controller, parts: &ctx.parts, }; if let Some(access_info_html) = controller.access_info_html(&ctx) { page.body.push_str(&access_info_html); } if entr.filemode() == FileMode::Link.into() { // TODO: indicate and link symbolic link } let blob = ctx.repo.find_blob(entr.id()).unwrap(); if let Some(mime) = mime_guess::from_path(&ctx.path).first() { if mime.type_() == "image" { page.body .push_str("
"); return Ok(page.into()); } } match from_utf8(blob.content()) { Ok(text) => { if let Some(renderer) = get_renderer(&ctx.path) { page.body.push_str(&renderer(text)); } else { page.body .push_str(&format!("
{}
", html_escape(text))); } } Err(_) => page.body.push_str("failed to decode file as UTF-8"), } Ok(page.into()) } "edit" => { if !controller.may_write_path(&ctx) { return Err(Error::Unauthorized( "you are not authorized to edit this file".into(), ctx, )); } let blob = ctx.repo.find_blob(entr.id()).unwrap(); if let Ok(text) = from_utf8(blob.content()) { return Ok(edit_text_form( &EditForm { text: text.to_string(), oid: Some(entr.id().to_string()), ..Default::default() }, None, controller, &ctx, ) .into()); } else { return Ok(upload_form(true, controller, &ctx).into()); } } "upload" => { return Ok(upload_form(true, controller, &ctx).into()); } "log" => { let log_param: LogParam = ctx.parts.query().unwrap(); if let Some(commit) = log_param.commit { let branch_commit = ctx.branch_head()?; let commit = ctx .repo .find_commit(Oid::from_str(&commit)?) .map_err(|_| Error::NotFound("commit not found".into()))?; if branch_commit.id() != commit.id() && !ctx .repo .graph_descendant_of(branch_commit.id(), commit.id())? { // disallow viewing commits from other branches you shouldn't have access to return Err(Error::NotFound("commit not found".into())); } let blob_id = if let Ok(entry) = commit.tree()?.get_path(&ctx.path) { entry.id() } else { return Ok(Page { title: format!("Commit for {}", filename), body: "file removed".into(), header: Some(action_links(¶ms.action, controller, &ctx)), controller, parts: &ctx.parts, } .into()); }; // TODO: if UTF-8 decoding fails, link ?action=raw&rev= // TODO: what if there are multiple parents? let old_blob_id = commit .parents() .next() .and_then(|p| p.tree().unwrap().get_path(&ctx.path).ok()) .map(|e| e.id()); if Some(blob_id) != old_blob_id { let mut page = Page { title: format!("Commit for {}", filename), header: Some(action_links(¶ms.action, controller, &ctx)), body: format!( "

{}

{} committed on {}", html_escape(commit.summary().unwrap_or_default()), html_escape(commit.author().name().unwrap_or_default()), NaiveDateTime::from_timestamp(commit.time().seconds(), 0) .format("%b %d, %Y, %H:%M") ), controller, parts: &ctx.parts, }; if let Some(old_blob_id) = old_blob_id { page.body.push_str(&diff( from_utf8(ctx.repo.find_blob(old_blob_id)?.content())?, from_utf8(ctx.repo.find_blob(blob_id)?.content())?, )) } else { page.body.push_str(&diff( "", from_utf8(&ctx.repo.find_blob(blob_id)?.content())?, )); } return Ok(page.into()); } else { return Err(Error::NotFound("commit not found".into())); } } let mut page = Page { title: format!("Log for {}", filename), body: String::new(), header: Some(action_links(¶ms.action, controller, &ctx)), controller, parts: &ctx.parts, }; let mut walk = ctx.repo.revwalk()?; let branch_head = ctx.branch_head()?; walk.push(branch_head.id())?; let mut prev_commit = branch_head; let mut prev_blobid = Some(prev_commit.tree()?.get_path(&ctx.path)?.id()); let mut commits = Vec::new(); // TODO: paginate for oid in walk.flatten().skip(1) { let commit = ctx.repo.find_commit(oid)?; if let Ok(entr) = commit.tree()?.get_path(&ctx.path) { let blobid = entr.id(); if Some(blobid) != prev_blobid { commits.push(prev_commit); prev_blobid = Some(blobid); } prev_commit = commit; } else { if prev_blobid.is_some() { commits.push(prev_commit); } prev_commit = commit; prev_blobid = None; } } if prev_commit.parent_count() == 0 && prev_commit.tree()?.get_path(&ctx.path).is_ok() { // the very first commit of the branch commits.push(prev_commit); } let mut prev_date = None; for c in commits { let date = NaiveDateTime::from_timestamp(c.time().seconds(), 0).date(); if Some(date) != prev_date { if prev_date != None { page.body.push_str(""); } page.body .push_str(&format!("{}
    ", date.format("%b %d, %Y"))); } page.body.push_str(&format!( "
  • {}: {}
  • ", html_escape(c.id().to_string()), html_escape(c.author().name().unwrap_or_default()), html_escape(c.summary().unwrap_or_default()), )); prev_date = Some(date); } page.body.push_str("
"); Ok(page.into()) } "raw" => { if let Some(etag) = ctx .parts .headers .get(header::IF_NONE_MATCH) .and_then(|v| v.to_str().ok()) { if etag.trim_matches('"') == entr.id().to_string() { return Ok(Builder::new() .status(StatusCode::NOT_MODIFIED) .body("".into()) .unwrap()); } } let blob = ctx.repo.find_blob(entr.id()).unwrap(); let mut resp = Response::new(blob.content().to_owned().into()); resp.headers_mut() .insert(header::ETAG, format!("\"{}\"", entr.id()).parse().unwrap()); resp.headers_mut() .insert(header::CACHE_CONTROL, "no-cache".parse().unwrap()); if let Some(mime) = mime_guess::from_path(&ctx.path).first() { if mime.type_() == "text" { // workaround for Firefox, which downloads non-plain text subtypes // instead of displaying them (https://bugzilla.mozilla.org/1319262) resp.headers_mut() .insert(header::CONTENT_TYPE, "text/plain".parse().unwrap()); } else { resp.headers_mut() .insert(header::CONTENT_TYPE, mime.to_string().parse().unwrap()); } } Ok(resp) } "move" => { if !controller.may_move_path(&ctx) { return Err(Error::Unauthorized( "you are not authorized to move this file".into(), ctx, )); } return move_form( filename, &MoveForm { dest: ctx.path.to_str().unwrap().to_owned(), msg: None, }, None, controller, &ctx, ); } "remove" => { if !controller.may_move_path(&ctx) { return Err(Error::Unauthorized( "you are not authorized to remove this file".into(), ctx, )); } let page = Page { title: format!("Remove {}", filename), controller, parts: &ctx.parts, header: Some(action_links(¶ms.action, controller, &ctx)), body: "
\ \
" .into(), }; Ok(page.into()) } _ => Err(Error::BadRequest("unknown action".into())), } } fn view_tree( tree: Result, controller: &C, ctx: &Context, ) -> Result { let mut page = Page { title: ctx.path.to_string_lossy().to_string(), controller, parts: &ctx.parts, body: String::new(), header: None, }; page.body.push_str("
    "); page.body .push_str("
  • ../
  • "); if let Ok(tree) = &tree { let mut entries: Vec<_> = tree.iter().collect(); entries.sort_by_key(|a| a.kind().unwrap().raw()); for entry in entries { // if the name isn't valid utf8 we skip the entry if let Some(name) = entry.name() { if entry.kind() == Some(ObjectType::Tree) { page.body.push_str(&format!( "
  • {0}/
  • ", html_escape(name) )); } else { page.body.push_str(&format!( "
  • {0}
  • ", html_escape(name) )); } } } } page.body.push_str("
"); controller.before_return_tree_page(&mut page, tree.ok(), ctx); Ok(page.into()) }