diff options
Diffstat (limited to 'src/main.rs')
-rw-r--r-- | src/main.rs | 1465 |
1 files changed, 1465 insertions, 0 deletions
diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..8c33e0f --- /dev/null +++ b/src/main.rs @@ -0,0 +1,1465 @@ +#![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<hyper::Body>; +pub(crate) type Request = hyper::Request<hyper::Body>; + +#[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<String>, + + /// Serve via the given Unix domain socket path. + #[cfg(unix)] + #[clap(long)] + socket: Option<String>, +} + +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<C: Controller + Send + Sync + 'static>(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<Utf8Error> for Error { + fn from(_: Utf8Error) -> Self { + Self::BadRequest("invalid UTF-8".into()) + } +} + +async fn service<C: Controller>( + controller: Arc<C>, + args: Arc<Args>, + request: Request, +) -> Result<Response, Infallible> { + 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<String>, + body: String, + controller: &'a dyn Controller, + parts: &'a Parts, +} + +impl From<Page<'_>> 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!( + "<!doctype html>\ + <html>\ + <head>\ + <meta charset=utf-8>\ + <title>{}</title>\ + <meta name=viewport content=\"width=device-width, initial-scale=1\">\ + <style>{}</style>\ + </head>\ + <body><header id=header>{}{}</header>{}</body></html>\ + ", + html_escape(&self.title), + CSS, + self.header.as_deref().unwrap_or_default(), + self.controller + .user_info_html(self.parts) + .map(|h| format!("<div class=user-info>{}</div>", h)) + .unwrap_or_default(), + self.body, + ) + } +} + +#[derive(Deserialize)] +struct ActionParam { + #[serde(default = "default_action")] + action: String, +} + +fn default_action() -> String { + "view".into() +} + +impl From<git2::Error> 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<C: Controller>( + args: Arc<Args>, + controller: &C, + parts: Parts, + body: Body, +) -> Result<Response, Error> { + 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("<p>Start by creating for example <a href='/~main/todo.md'>/~main/todo.md</a>.</p>"); + } + } else { + page.body.push_str("the following branches:"); + page.body.push_str("<ul>"); + for (branch, _) in repo.branches(Some(BranchType::Local))?.flatten() { + page.body.push_str(&format!( + "<li><a href='~{0}/'>~{0}</a></li>", + html_escape(branch.name()?.unwrap()) + )); + } + page.body.push_str("</ul>"); + } + + 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::<ActionParam>().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 <a href=?action=edit>you can write it</a> or <a href=?action=upload>upload it</a>".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!( + " <a {}{}>{}</a>", + if name == active_action { + "class=active".into() + } else { + format!("href=?action={}", name) + }, + if name != label { + format!(" title='{}'", name) + } else { + "".into() + }, + label + ) +} + +fn action_links<C: Controller>(active_action: &str, controller: &C, ctx: &Context) -> String { + let mut out = String::new(); + + out.push_str("<div class=actions>"); + out.push_str("<a href=. title='list parent directory'>ls</a>"); + 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("</div>"); + out +} + +impl From<FormError> for Error { + fn from(e: FormError) -> Self { + Self::BadRequest(e.to_string()) + } +} + +async fn upload_blob<C: Controller>( + body: Body, + controller: &C, + mut ctx: Context, +) -> Result<Response, Error> { + 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<C: Controller>( + data: &[u8], + msg: Option<String>, + 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::<Vec<_>>()[..], + )?; + + Ok(()) +} + +async fn preview_edit<C: Controller>( + body: Body, + controller: &C, + ctx: Context, +) -> Result<Response, Error> { + 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<C: Controller>( + body: Body, + controller: &C, + ctx: Context, +) -> Result<Response, Error> { + 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 "<em>(no changes)</em>".into(); + } + + let Changeset { diffs, .. } = Changeset::new(&first, &second, "\n"); + + let mut output = String::new(); + + output.push_str("<pre>"); + + 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("<div class=addition>"); + if i == 0 { + output.push_str(&html_escape(text).replace("\n", "<br>")); + } 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", "<br>")); + } + } + } + output.push_str("\n</div>"); + } + Difference::Rem(ref text) => { + output.push_str("<div class=deletion>"); + 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", "<br>")); + } + } + output.push_str("\n</div>"); + } + } + } + + output.push_str("</pre>"); + 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", "<br>")); + out.push(' '); + } + Difference::Add(ref z) => { + write!( + out, + "<{0}>{1}</{0}> ", + tagname, + html_escape(z).replace("\n", "<br>") + ) + .expect("write error"); + } + _ => {} + } + } +} + +#[derive(Deserialize, Default)] +struct EditForm { + text: String, + msg: Option<String>, + oid: Option<String>, +} + +async fn update_blob<C: Controller>( + body: Body, + controller: &C, + mut ctx: Context, +) -> Result<Response, Error> { + 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<String>, +} + +async fn move_entry<C: Controller>( + body: Body, + controller: &C, + ctx: Context, +) -> Result<Response, Error> { + 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<String>, +} + +async fn remove_entry<C: Controller>( + body: Body, + controller: &C, + ctx: Context, +) -> Result<Response, Error> { + 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!("<div class=error>error: {}</div>", html_escape(message)) +} + +pub struct Context { + repo: Repository, + parts: Parts, + branch: Branch, + path: PathBuf, +} + +impl Context { + fn branch_head(&self) -> Result<Commit, Error> { + 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<Oid, git2::Error> { + 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: "<form action=?action=upload method=post enctype='multipart/form-data'>\ + <input name=file type=file>\ + <button>Upload</button>\ + </form>" + .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("<div class=markdown-output>"); + html::push_html(&mut out, parser); + out.push_str("</div>"); + out +} + +fn get_renderer(path: &Path) -> Option<fn(&str) -> 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!("<div class=edit-hint>{}</div>", hint_html)); + } + if let Some(error) = error { + page.body.push_str(&render_error(error)); + } + page.body.push_str(&format!( + "<form method=post action='?action=edit' class=edit-form>\ + <textarea name=text autofocus autocomplete=off>{}</textarea>", + html_escape(&data.text) + )); + page.body + .push_str("<div class=buttons><button>Save</button>"); + if let Some(oid) = &data.oid { + page.body.push_str(&format!( + "<input name=oid type=hidden value='{}'> + <button formaction='?action=diff'>Diff</button>", + oid + )); + } + if get_renderer(&ctx.path).is_some() { + page.body + .push_str(" <button formaction='?action=preview'>Preview</button>") + } + page.body.push_str(&format!( + "<input name=msg placeholder=Message value='{}' autocomplete=off></div></form>", + html_escape(data.msg.as_deref().unwrap_or_default()) + )); + + page.body.push_str(&format!( + "<script>{}</script>", + include_str!("static/edit_script.js") + )); + page +} + +fn move_form<C: Controller>( + filename: &str, + data: &MoveForm, + error: Option<&str>, + controller: &C, + ctx: &Context, +) -> Result<Response, Error> { + 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!( + "<form method=post autocomplete=off> + <label>Destination <input name=dest value='{}' autofocus></label> + <label>Message <input name=msg value='{}'></label> + <button>Move</button> + </form>", + html_escape(&data.dest), + data.msg.as_ref().map(html_escape).unwrap_or_default(), + )); + + Ok(page.into()) +} + +#[derive(Deserialize)] +struct LogParam { + commit: Option<String>, +} + +fn view_blob<C: Controller>( + entr: TreeEntry, + params: ActionParam, + controller: &C, + ctx: Context, +) -> Result<Response, Error> { + 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("<div class=img-container><img src=?action=raw></div>"); + 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!("<pre>{}</pre>", 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!( + "<h1>{}</h1>{} 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("</ul>"); + } + page.body + .push_str(&format!("{}<ul>", date.format("%b %d, %Y"))); + } + + page.body.push_str(&format!( + "<li><a href='?action=log&commit={}'>{}: {}</a></li>", + 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("</ul>"); + 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: "<form method=post autocomplete=off>\ + <label>Message <input name=msg autofocus></label>\ + <button>Remove</button></form>" + .into(), + }; + + Ok(page.into()) + } + _ => Err(Error::BadRequest("unknown action".into())), + } +} + +fn view_tree<C: Controller>( + tree: Result<Tree, git2::Error>, + controller: &C, + ctx: &Context, +) -> Result<Response, Error> { + 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("<ul>"); + page.body + .push_str("<li><a href='..' title='go to parent directory'>../</a></li>"); + + 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!( + "<li><a href='{0}/'>{0}/</a></li>", + html_escape(name) + )); + } else { + page.body.push_str(&format!( + "<li><a href='{0}'>{0}</a></li>", + html_escape(name) + )); + } + } + } + } + page.body.push_str("</ul>"); + + controller.before_return_tree_page(&mut page, tree.ok(), ctx); + + Ok(page.into()) +} |