use git2::build::TreeUpdateBuilder; use git2::FileMode; use hyper::header; use hyper::http::request::Parts; use hyper::http::response::Builder; use hyper::Body; use hyper::StatusCode; use multer::Multipart; use serde::Deserialize; use sputnik::hyper_body::SputnikBody; use std::path::Path; use std::str::from_utf8; use crate::diff::diff; use crate::forms::edit_text_form; use crate::forms::move_form; use crate::forms::EditForm; use crate::forms::MoveForm; use crate::get_renderer; use crate::ActionParam; use crate::Context; use crate::HttpOrigin; use crate::RenderMode; use crate::Response; use crate::{controller::Controller, Error}; pub(crate) async fn build_response( host: &HttpOrigin, params: &ActionParam, controller: &C, ctx: Context, body: Body, parts: &mut Parts, ) -> Result { if parts .headers .get(header::ORIGIN) .filter(|h| h.as_bytes() == host.origin.as_bytes()) .is_none() { // This check prevents cross-site request forgery (CSRF). return Err(Error::BadRequest(format!( "POST requests must be sent with the header Origin: {}", host.origin ))); } match params.action.as_ref() { "edit" => return update_blob(body, controller, ctx, parts).await, "upload" => return upload_blob(body, controller, ctx, parts).await, "move" => return move_entry(body, controller, ctx, parts).await, "remove" => return remove_entry(body, controller, ctx, parts).await, "diff" => return diff_blob(body, controller, ctx, parts).await, "preview" => return preview_edit(body, controller, ctx, parts).await, _ => return Err(Error::BadRequest("unknown POST action".into())), } } fn commit_file_update( data: &[u8], msg: Option, controller: &C, ctx: &Context, parts: &Parts, ) -> Result<(), Error> { let blob_id = ctx.repo.blob(data)?; let mut builder = TreeUpdateBuilder::new(); builder.upsert(ctx.path.as_str(), 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.as_ref()) .ok() .map(|e| e.id()) == Some(blob_id) { // nothing changed, don't create an empty commit return Err(Error::Redirect(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, &parts)?; ctx.commit( &signature, &msg.filter(|m| !m.trim().is_empty()).unwrap_or_else(|| { format!( "{} {}", if parent_tree.get_path(&ctx.path.as_ref()).is_ok() { "edit" } else { "create" }, ctx.path ) }), &ctx.repo.find_tree(new_tree_id)?, &parent_commits.iter().collect::>()[..], )?; Ok(()) } async fn update_blob( body: Body, controller: &C, ctx: Context, parts: &mut Parts, ) -> Result { if !controller.may_write_path(&ctx, parts) { return Err(Error::Unauthorized( "you are not authorized to edit this file".into(), )); } 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.as_ref()) .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, parts).into()); } } // normalize newlines as per HTML spec let text = data.text.replace("\r\n", "\n"); if let Err(error) = controller.before_write(&text, &ctx, parts) { return Ok(edit_text_form(&data, Some(&error), controller, &ctx, parts).into()); } commit_file_update(text.as_bytes(), data.msg, controller, &ctx, &parts)?; controller.after_write(&ctx, parts); return Ok(Builder::new() .status(StatusCode::FOUND) .header("location", parts.uri.path()) .body("redirecting".into()) .unwrap() .into()); } async fn upload_blob( body: Body, controller: &C, ctx: Context, parts: &mut Parts, ) -> Result { if !controller.may_write_path(&ctx, parts) { return Err(Error::Unauthorized( "you are not authorized to edit this file".into(), )); } // Extract the `multipart/form-data` boundary from the headers. let boundary = 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, &parts, )?; controller.after_write(&ctx, parts); return Ok(Builder::new() .status(StatusCode::FOUND) .header("location", parts.uri.path()) .body("redirecting".into()) .unwrap() .into()); } } Err(Error::BadRequest( "expected file upload named 'file'".into(), )) } async fn move_entry( body: Body, controller: &C, ctx: Context, parts: &Parts, ) -> Result { if !controller.may_move_path(&ctx, parts) { return Err(Error::Unauthorized( "you are not authorized to move this file".into(), )); } let mut data: MoveForm = body.into_form().await?; let filename = ctx.path.file_name().unwrap(); if ctx.path == Path::new(&data.dest) { return move_form( filename, &data, Some("can not move entry to itself"), controller, &ctx, parts, ); } 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, parts, ); } let mut builder = TreeUpdateBuilder::new(); let entr = parent_tree.get_path(&ctx.path.as_ref())?; builder.remove(&ctx.path.as_str()); 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, &parts)?, &data .msg .take() .filter(|m| !m.trim().is_empty()) .unwrap_or_else(|| format!("move {} to {}", ctx.path, data.dest)), &ctx.repo.find_tree(new_tree_id)?, &[&parent_commit], )?; Ok(Builder::new() .status(StatusCode::FOUND) .header( "location", controller.build_url_path(&ctx.branch, &data.dest), ) .body("redirecting".into()) .unwrap() .into()) } #[derive(Deserialize)] struct RemoveForm { msg: Option, } async fn remove_entry( body: Body, controller: &C, ctx: Context, parts: &Parts, ) -> Result { if !controller.may_move_path(&ctx, parts) { return Err(Error::Unauthorized( "you are not authorized to remove this file".into(), )); } let data: RemoveForm = body.into_form().await?; let mut builder = TreeUpdateBuilder::new(); builder.remove(&ctx.path.as_str()); let parent_commit = ctx.branch_head()?; let new_tree_id = builder.create_updated(&ctx.repo, &parent_commit.tree()?)?; ctx.commit( &controller.signature(&ctx.repo, &parts)?, &data .msg .filter(|m| !m.trim().is_empty()) .unwrap_or_else(|| format!("remove {}", ctx.path)), &ctx.repo.find_tree(new_tree_id)?, &[&parent_commit], )?; Ok(Builder::new() .status(StatusCode::FOUND) .header( "location", controller.build_url_path(&ctx.branch, ctx.path.parent().unwrap().as_str()), ) .body("redirecting".into()) .unwrap() .into()) } async fn diff_blob( body: Body, controller: &C, ctx: Context, parts: &Parts, ) -> Result { if !controller.may_write_path(&ctx, parts) { return Err(Error::Unauthorized( "you are not authorized to edit this file".into(), )); } 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.as_ref())?; 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, parts); page.body.push_str(&diff(old_text, &new_text)); Ok(page.into()) } async fn preview_edit( body: Body, controller: &C, ctx: Context, parts: &Parts, ) -> 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, parts); get_renderer(&ctx.path).unwrap()(&new_text, &mut page, RenderMode::Preview); Ok(page.into()) }