diff options
author | Martin Fischer <martin@push-f.com> | 2021-06-23 14:12:02 +0200 |
---|---|---|
committer | Martin Fischer <martin@push-f.com> | 2021-06-23 14:36:17 +0200 |
commit | 3ed32ef268b54965c97b13efdf24a1539c4ec1c1 (patch) | |
tree | a0f80b097339693763a0bce2896c083c1000c6cc | |
parent | 086e0e30e47f796a12809036dfc787490904a8ae (diff) |
refactor: split up main.rs into multiple modules
-rw-r--r-- | src/diff.rs | 86 | ||||
-rw-r--r-- | src/forms.rs | 137 | ||||
-rw-r--r-- | src/get_routes.rs | 361 | ||||
-rw-r--r-- | src/main.rs | 906 | ||||
-rw-r--r-- | src/post_routes.rs | 354 |
5 files changed, 953 insertions, 891 deletions
diff --git a/src/diff.rs b/src/diff.rs new file mode 100644 index 0000000..382c7db --- /dev/null +++ b/src/diff.rs @@ -0,0 +1,86 @@ +use difference::Changeset; +use difference::Difference; +use sputnik::html_escape; +use std::cmp; +use std::fmt::Write as FmtWrite; + +pub 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"); + } + _ => {} + } + } +} diff --git a/src/forms.rs b/src/forms.rs new file mode 100644 index 0000000..fc16d09 --- /dev/null +++ b/src/forms.rs @@ -0,0 +1,137 @@ +use serde::Deserialize; +use sputnik::html_escape; + +use crate::{action_links, controller::Controller, get_renderer, Context, Error, Page, Response}; + +fn render_error(message: &str) -> String { + format!("<div class=error>error: {}</div>", html_escape(message)) +} + +#[derive(Deserialize, Default)] +pub struct EditForm { + pub text: String, + pub msg: Option<String>, + pub oid: Option<String>, +} + +pub 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 +} + +#[derive(Deserialize)] +pub struct MoveForm { + pub dest: String, + pub msg: Option<String>, +} + +pub 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()) +} + +pub 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, + } +} diff --git a/src/get_routes.rs b/src/get_routes.rs new file mode 100644 index 0000000..433e676 --- /dev/null +++ b/src/get_routes.rs @@ -0,0 +1,361 @@ +use chrono::NaiveDateTime; +use git2::FileMode; +use git2::ObjectType; +use git2::Oid; +use git2::Tree; +use git2::TreeEntry; +use hyper::header; +use hyper::http::response::Builder; +use hyper::StatusCode; +use serde::Deserialize; +use sputnik::html_escape; +use sputnik::request::SputnikParts; +use std::str::from_utf8; + +use crate::action_links; +use crate::controller::Controller; +use crate::diff::diff; +use crate::forms; +use crate::get_renderer; +use crate::ActionParam; +use crate::Context; +use crate::Error; +use crate::Page; +use crate::Response; + +#[derive(Deserialize)] +struct LogParam { + commit: Option<String>, +} + +pub(crate) 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(forms::edit_text_form( + &forms::EditForm { + text: text.to_string(), + oid: Some(entr.id().to_string()), + ..Default::default() + }, + None, + controller, + &ctx, + ) + .into()); + } else { + return Ok(forms::upload_form(true, controller, &ctx).into()); + } + } + "upload" => { + return Ok(forms::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 forms::move_form( + filename, + &forms::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())), + } +} + +pub 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()) +} diff --git a/src/main.rs b/src/main.rs index 8c33e0f..f247ba7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,20 +1,14 @@ #![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; @@ -22,7 +16,6 @@ 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; @@ -30,18 +23,14 @@ 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; @@ -56,6 +45,10 @@ 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<hyper::Body>; pub(crate) type Request = hyper::Request<hyper::Body>; @@ -442,31 +435,7 @@ async fn build_response<C: Controller>( } 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())); - } - } + return post_routes::build_response(&args, ¶ms, controller, ctx, body).await; } let mut tree = ctx @@ -484,11 +453,15 @@ async fn build_response<C: Controller>( if controller.may_write_path(&ctx) { if params.action == "edit" { - return Ok( - edit_text_form(&EditForm::default(), None, controller, &ctx).into() - ); + return Ok(forms::edit_text_form( + &forms::EditForm::default(), + None, + controller, + &ctx, + ) + .into()); } else if params.action == "upload" { - return Ok(upload_form(false, controller, &ctx).into()); + return Ok(forms::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(), @@ -511,7 +484,7 @@ async fn build_response<C: Controller>( .body("redirecting".into()) .unwrap()); } - return view_blob(entr, params, controller, ctx); + return get_routes::view_blob(entr, params, controller, ctx); } tree = ctx.repo.find_tree(entr.id()); @@ -520,7 +493,7 @@ async fn build_response<C: Controller>( } } - view_tree(tree, controller, &ctx) + get_routes::view_tree(tree, controller, &ctx) } fn render_link(name: &str, label: &str, active_action: &str) -> String { @@ -565,402 +538,6 @@ impl From<FormError> for Error { } } -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, @@ -995,26 +572,6 @@ impl Context { } } -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(); @@ -1030,436 +587,3 @@ fn get_renderer(path: &Path) -> Option<fn(&str) -> String> { _ => 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()) -} diff --git a/src/post_routes.rs b/src/post_routes.rs new file mode 100644 index 0000000..0d4631c --- /dev/null +++ b/src/post_routes.rs @@ -0,0 +1,354 @@ +use git2::build::TreeUpdateBuilder; +use git2::FileMode; +use hyper::header; +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::build_url_path; +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::Args; +use crate::Context; +use crate::Response; +use crate::{controller::Controller, Error}; + +pub(crate) async fn build_response<C: Controller>( + args: &Args, + params: &ActionParam, + controller: &C, + ctx: Context, + body: Body, +) -> Result<Response, Error> { + 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())), + } +} + +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 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()); +} + +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(), + )) +} + +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()) +} + +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()) +} + +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()) +} |