aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMartin Fischer <martin@push-f.com>2021-06-23 14:12:02 +0200
committerMartin Fischer <martin@push-f.com>2021-06-23 14:36:17 +0200
commit3ed32ef268b54965c97b13efdf24a1539c4ec1c1 (patch)
treea0f80b097339693763a0bce2896c083c1000c6cc
parent086e0e30e47f796a12809036dfc787490904a8ae (diff)
refactor: split up main.rs into multiple modules
-rw-r--r--src/diff.rs86
-rw-r--r--src/forms.rs137
-rw-r--r--src/get_routes.rs361
-rw-r--r--src/main.rs906
-rw-r--r--src/post_routes.rs354
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(&params.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(&params.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(&params.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(&params.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(&params.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, &params, 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(&params.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(&params.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(&params.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(&params.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(&params.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())
+}