aboutsummaryrefslogtreecommitdiff
path: root/src/main.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/main.rs')
-rw-r--r--src/main.rs1465
1 files changed, 1465 insertions, 0 deletions
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..8c33e0f
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,1465 @@
+#![feature(try_blocks)]
+
+use chrono::NaiveDateTime;
+use clap::Clap;
+use controller::Controller;
+use difference::Changeset;
+use difference::Difference;
+use git2::build::TreeUpdateBuilder;
+use git2::BranchType;
+use git2::Commit;
+use git2::FileMode;
+use git2::ObjectType;
+use git2::Oid;
+use git2::Repository;
+use git2::Signature;
+use git2::Tree;
+use git2::TreeEntry;
+use hyper::header;
+use hyper::http::request::Parts;
+use hyper::http::response::Builder;
+use hyper::service::{make_service_fn, service_fn};
+use hyper::Method;
+use hyper::StatusCode;
+use hyper::{Body, Server};
+use multer::Multipart;
+use percent_encoding::percent_decode_str;
+use pulldown_cmark::html;
+use pulldown_cmark::Options;
+use pulldown_cmark::Parser;
+use serde::Deserialize;
+use sputnik::html_escape;
+use sputnik::hyper_body::FormError;
+use sputnik::hyper_body::SputnikBody;
+use sputnik::mime;
+use sputnik::request::SputnikParts;
+use sputnik::response::SputnikBuilder;
+use std::cmp;
+use std::convert::Infallible;
+use std::env;
+use std::fmt::Write as FmtWrite;
+use std::path::Component;
+use std::path::Path;
+use std::path::PathBuf;
+use std::str::from_utf8;
+use std::str::Utf8Error;
+use std::sync::Arc;
+use url::Url;
+
+#[cfg(unix)]
+use {
+ hyperlocal::UnixServerExt, std::fs, std::fs::Permissions,
+ std::os::unix::prelude::PermissionsExt,
+};
+
+use crate::controller::MultiUserController;
+use crate::controller::SoloController;
+
+mod controller;
+
+pub(crate) type Response = hyper::Response<hyper::Body>;
+pub(crate) type Request = hyper::Request<hyper::Body>;
+
+#[derive(Clap, Debug)]
+#[clap(name = "gitpad")]
+struct Args {
+ /// Enable mutliuser mode (requires a reverse-proxy that handles
+ /// authentication and sets the Username header)
+ #[clap(short)]
+ multiuser: bool,
+
+ #[clap(short, default_value = "8000")]
+ port: u16,
+
+ /// Enforce the given HTTP Origin header value to prevent CSRF attacks.
+ #[clap(long, validator = validate_origin)]
+ origin: Option<String>,
+
+ /// Serve via the given Unix domain socket path.
+ #[cfg(unix)]
+ #[clap(long)]
+ socket: Option<String>,
+}
+
+fn validate_origin(input: &str) -> Result<(), String> {
+ let url = Url::parse(input).map_err(|e| e.to_string())?;
+ if url.scheme() != "http" && url.scheme() != "https" {
+ return Err("must start with http:// or https://".into());
+ }
+ if url.path() != "/" {
+ return Err("must not have a path".into());
+ }
+ if input.ends_with('/') {
+ return Err("must not end with a trailing slash".into());
+ }
+ Ok(())
+}
+
+#[tokio::main]
+async fn main() {
+ let args = Args::parse();
+ let repo = Repository::open_bare(env::current_dir().unwrap())
+ .expect("expected current directory to be a bare Git repository");
+
+ if args.origin.is_none() {
+ eprintln!(
+ "[warning] Running gitpad without --origin might \
+ make you vulnerable to CSRF attacks."
+ );
+ }
+
+ if args.multiuser {
+ serve(MultiUserController::new(&repo), args).await;
+ } else {
+ serve(SoloController, args).await;
+ }
+}
+
+async fn serve<C: Controller + Send + Sync + 'static>(controller: C, args: Args) {
+ let controller = Arc::new(controller);
+ let args = Arc::new(args);
+ let server_args = args.clone();
+
+ #[cfg(unix)]
+ if let Some(socket_path) = &server_args.socket {
+ // TODO: get rid of code duplication
+ // we somehow need to specify the closure type or it gets too specific
+ let service = make_service_fn(move |_| {
+ let controller = controller.clone();
+ let args = args.clone();
+
+ async move {
+ Ok::<_, hyper::Error>(service_fn(move |req| {
+ service(controller.clone(), args.clone(), req)
+ }))
+ }
+ });
+ let path = Path::new(&socket_path);
+ if path.exists() {
+ fs::remove_file(path).unwrap();
+ }
+ let server = Server::bind_unix(path).unwrap();
+
+ if fs::metadata(path.parent().unwrap())
+ .unwrap()
+ .permissions()
+ .mode()
+ & 0o001
+ != 0
+ {
+ eprintln!("socket parent directory must not have x permission for others");
+ std::process::exit(1);
+ }
+
+ fs::set_permissions(path, Permissions::from_mode(0o777))
+ .expect("failed to set socket permissions");
+
+ println!("Listening on unix socket {}", socket_path);
+ server.serve(service).await.expect("server error");
+ return;
+ }
+
+ eprint!(
+ "[warning] Serving GitPad over a TCP socket. \
+ If you use a reverse-proxy for access control, \
+ it can be circumvented by anybody with a system account."
+ );
+ #[cfg(unix)]
+ eprint!(
+ " Use a Unix domain socket (with --socket) to restrict \
+ access based on the socket parent directory permissions."
+ );
+ eprintln!();
+
+ let service = make_service_fn(move |_| {
+ let controller = controller.clone();
+ let args = args.clone();
+
+ async move {
+ Ok::<_, hyper::Error>(service_fn(move |req| {
+ service(controller.clone(), args.clone(), req)
+ }))
+ }
+ });
+ let addr = ([127, 0, 0, 1], server_args.port).into();
+ let server = Server::bind(&addr).serve(service);
+ println!("Listening on http://{}", addr);
+ server.await.expect("server error");
+}
+
+pub enum Error {
+ /// A 400 bad request error.
+ BadRequest(String),
+ /// A 401 unauthorized error.
+ Unauthorized(String, Context),
+ /// A 403 forbidden error.
+ Forbidden(String),
+ /// A 404 not found error.
+ NotFound(String),
+ /// A 500 internal server error.
+ Internal(String),
+ /// A 302 redirect to the given path.
+ Redirect(String),
+
+ // TODO: use Redirect instead
+ /// Missing trailing slash.
+ MissingTrailingSlash(Parts),
+}
+
+impl From<Utf8Error> for Error {
+ fn from(_: Utf8Error) -> Self {
+ Self::BadRequest("invalid UTF-8".into())
+ }
+}
+
+async fn service<C: Controller>(
+ controller: Arc<C>,
+ args: Arc<Args>,
+ request: Request,
+) -> Result<Response, Infallible> {
+ let (parts, body) = request.into_parts();
+
+ let mut resp = build_response(args, &*controller, parts, body)
+ .await
+ .unwrap_or_else(|err| {
+ if let Some(resp) = controller.before_return_error(&err) {
+ return resp;
+ }
+ let (status, message) = match err {
+ Error::BadRequest(msg) => (400, msg),
+ Error::Unauthorized(msg, _ctx) => (401, msg),
+ Error::Forbidden(msg) => (403, msg),
+ Error::NotFound(msg) => (404, msg),
+ Error::Internal(msg) => (500, msg),
+ Error::MissingTrailingSlash(parts) => {
+ return Builder::new()
+ .status(StatusCode::FOUND)
+ .header("location", format!("{}/", parts.uri.path()))
+ .body("redirecting".into())
+ .unwrap();
+ }
+ Error::Redirect(target) => {
+ return Builder::new()
+ .status(StatusCode::FOUND)
+ .header("location", target)
+ .body("redirecting".into())
+ .unwrap();
+ }
+ };
+ // TODO: use Page
+ Builder::new()
+ .status(status)
+ .header("content-type", "text/html")
+ .body(message.into())
+ .unwrap()
+ });
+
+ // we rely on CSP to thwart XSS attacks, all modern browsers support it
+ resp.headers_mut().insert(
+ header::CONTENT_SECURITY_POLICY,
+ format!(
+ "child-src 'none'; script-src 'sha256-{}'; style-src 'sha256-{}'",
+ include_str!("static/edit_script.js.sha256"),
+ include_str!("static/style.css.sha256"),
+ )
+ .parse()
+ .unwrap(),
+ );
+ Ok(resp)
+}
+
+pub struct Page<'a> {
+ title: String,
+ header: Option<String>,
+ body: String,
+ controller: &'a dyn Controller,
+ parts: &'a Parts,
+}
+
+impl From<Page<'_>> for Response {
+ fn from(page: Page) -> Self {
+ Builder::new()
+ .content_type(mime::TEXT_HTML)
+ .body(page.render().into())
+ .unwrap()
+ }
+}
+
+const CSS: &str = include_str!("static/style.css");
+
+impl Page<'_> {
+ fn render(&self) -> String {
+ format!(
+ "<!doctype html>\
+ <html>\
+ <head>\
+ <meta charset=utf-8>\
+ <title>{}</title>\
+ <meta name=viewport content=\"width=device-width, initial-scale=1\">\
+ <style>{}</style>\
+ </head>\
+ <body><header id=header>{}{}</header>{}</body></html>\
+ ",
+ html_escape(&self.title),
+ CSS,
+ self.header.as_deref().unwrap_or_default(),
+ self.controller
+ .user_info_html(self.parts)
+ .map(|h| format!("<div class=user-info>{}</div>", h))
+ .unwrap_or_default(),
+ self.body,
+ )
+ }
+}
+
+#[derive(Deserialize)]
+struct ActionParam {
+ #[serde(default = "default_action")]
+ action: String,
+}
+
+fn default_action() -> String {
+ "view".into()
+}
+
+impl From<git2::Error> for Error {
+ fn from(e: git2::Error) -> Self {
+ eprintln!("git error: {}", e);
+ Self::Internal("something went wrong with git".into())
+ }
+}
+
+/// Builds a URL path from a given Git revision and filepath.
+fn build_url_path(rev: &Branch, path: &str) -> String {
+ format!("/~{}/{}", rev.0, path)
+}
+
+#[derive(Eq, PartialEq, Hash, Clone)]
+pub struct Branch(String);
+
+impl Branch {
+ fn rev_str(&self) -> String {
+ format!("refs/heads/{}", self.0)
+ }
+}
+
+async fn build_response<C: Controller>(
+ args: Arc<Args>,
+ controller: &C,
+ parts: Parts,
+ body: Body,
+) -> Result<Response, Error> {
+ controller.before_route(&parts)?;
+ let unsanitized_path = percent_decode_str(parts.uri.path())
+ .decode_utf8()
+ .map_err(|_| Error::BadRequest("failed to percent-decode path as UTF-8".into()))?
+ .into_owned();
+
+ let repo = Repository::open_bare(env::current_dir().unwrap()).unwrap();
+
+ if parts.uri.path() == "/" {
+ // TODO: add domain name to title?
+ let mut page = Page {
+ title: "GitPad".into(),
+ controller,
+ parts: &parts,
+ body: String::new(),
+ header: None,
+ };
+
+ let branches: Vec<_> = repo.branches(Some(BranchType::Local))?.collect();
+
+ page.body.push_str("This GitPad instance has ");
+
+ if branches.is_empty() {
+ page.body.push_str("no branches yet.");
+
+ if !args.multiuser {
+ page.body.push_str("<p>Start by creating for example <a href='/~main/todo.md'>/~main/todo.md</a>.</p>");
+ }
+ } else {
+ page.body.push_str("the following branches:");
+ page.body.push_str("<ul>");
+ for (branch, _) in repo.branches(Some(BranchType::Local))?.flatten() {
+ page.body.push_str(&format!(
+ "<li><a href='~{0}/'>~{0}</a></li>",
+ html_escape(branch.name()?.unwrap())
+ ));
+ }
+ page.body.push_str("</ul>");
+ }
+
+ return Ok(page.into());
+ }
+
+ let mut iter = unsanitized_path.splitn(3, '/');
+ iter.next();
+ let rev = iter.next().unwrap();
+ if !rev.starts_with('~') {
+ return Err(Error::NotFound(
+ "branch name must be prefixed a tilde (~)".into(),
+ ));
+ }
+ let rev = &rev[1..];
+ if rev.trim().is_empty() {
+ return Err(Error::NotFound("invalid branch name".into()));
+ }
+ let rev = Branch(rev.to_owned());
+ let unsanitized_path = match iter.next() {
+ Some(value) => value,
+ None => return Err(Error::MissingTrailingSlash(parts)),
+ };
+
+ let mut comps = Vec::new();
+
+ // prevent directory traversal attacks
+ for comp in Path::new(&*unsanitized_path).components() {
+ match comp {
+ Component::Normal(name) => comps.push(name),
+ Component::ParentDir => {
+ return Err(Error::Forbidden("path traversal is forbidden".into()))
+ }
+ _ => {}
+ }
+ }
+
+ let params: ActionParam = parts.query::<ActionParam>().unwrap();
+
+ let url_path: PathBuf = comps.iter().collect();
+
+ let ctx = Context {
+ repo,
+ path: url_path,
+ branch: rev,
+ parts,
+ };
+
+ if !controller.may_read_path(&ctx) {
+ return Err(Error::Unauthorized(
+ "you are not authorized to view this file".into(),
+ ctx,
+ ));
+ }
+
+ if ctx.parts.method == Method::POST {
+ if let Some(ref enforced_origin) = args.origin {
+ if ctx
+ .parts
+ .headers
+ .get(header::ORIGIN)
+ .filter(|h| h.as_bytes() == enforced_origin.as_bytes())
+ .is_none()
+ {
+ return Err(Error::BadRequest(format!(
+ "POST requests must be sent with the header Origin: {}",
+ enforced_origin
+ )));
+ }
+ }
+ match params.action.as_ref() {
+ "edit" => return update_blob(body, controller, ctx).await,
+ "upload" => return upload_blob(body, controller, ctx).await,
+ "move" => return move_entry(body, controller, ctx).await,
+ "remove" => return remove_entry(body, controller, ctx).await,
+ "diff" => return diff_blob(body, controller, ctx).await,
+ "preview" => return preview_edit(body, controller, ctx).await,
+ _ => {
+ return Err(Error::BadRequest("unknown POST action".into()));
+ }
+ }
+ }
+
+ let mut tree = ctx
+ .repo
+ .revparse_single(&ctx.branch.rev_str())
+ .map(|r| r.into_commit().unwrap().tree().unwrap());
+
+ if ctx.path.components().next().is_some() {
+ let entr = match tree.and_then(|t| t.get_path(&ctx.path)) {
+ Ok(entr) => entr,
+ Err(_) => {
+ if unsanitized_path.ends_with('/') {
+ return Err(Error::NotFound("directory not found".into()));
+ }
+
+ if controller.may_write_path(&ctx) {
+ if params.action == "edit" {
+ return Ok(
+ edit_text_form(&EditForm::default(), None, controller, &ctx).into()
+ );
+ } else if params.action == "upload" {
+ return Ok(upload_form(false, controller, &ctx).into());
+ } else {
+ return Err(Error::NotFound(
+ "file not found, but <a href=?action=edit>you can write it</a> or <a href=?action=upload>upload it</a>".into(),
+ ));
+ }
+ } else {
+ return Err(Error::NotFound("file not found".into()));
+ }
+ }
+ };
+
+ if entr.kind().unwrap() == ObjectType::Blob {
+ if unsanitized_path.ends_with('/') {
+ return Ok(Builder::new()
+ .status(StatusCode::FOUND)
+ .header(
+ "location",
+ build_url_path(&ctx.branch, unsanitized_path.trim_end_matches('/')),
+ )
+ .body("redirecting".into())
+ .unwrap());
+ }
+ return view_blob(entr, params, controller, ctx);
+ }
+
+ tree = ctx.repo.find_tree(entr.id());
+ if !unsanitized_path.ends_with('/') {
+ return Err(Error::MissingTrailingSlash(ctx.parts));
+ }
+ }
+
+ view_tree(tree, controller, &ctx)
+}
+
+fn render_link(name: &str, label: &str, active_action: &str) -> String {
+ format!(
+ " <a {}{}>{}</a>",
+ if name == active_action {
+ "class=active".into()
+ } else {
+ format!("href=?action={}", name)
+ },
+ if name != label {
+ format!(" title='{}'", name)
+ } else {
+ "".into()
+ },
+ label
+ )
+}
+
+fn action_links<C: Controller>(active_action: &str, controller: &C, ctx: &Context) -> String {
+ let mut out = String::new();
+
+ out.push_str("<div class=actions>");
+ out.push_str("<a href=. title='list parent directory'>ls</a>");
+ out.push_str(&render_link("view", "view", active_action));
+ if controller.may_write_path(ctx) {
+ out.push_str(&render_link("edit", "edit", active_action));
+ }
+ out.push_str(&render_link("log", "log", active_action));
+ out.push_str(&render_link("raw", "raw", active_action));
+ if controller.may_move_path(ctx) {
+ out.push_str(&render_link("move", "mv", active_action));
+ out.push_str(&render_link("remove", "rm", active_action));
+ }
+ out.push_str("</div>");
+ out
+}
+
+impl From<FormError> for Error {
+ fn from(e: FormError) -> Self {
+ Self::BadRequest(e.to_string())
+ }
+}
+
+async fn upload_blob<C: Controller>(
+ body: Body,
+ controller: &C,
+ mut ctx: Context,
+) -> Result<Response, Error> {
+ if !controller.may_write_path(&ctx) {
+ return Err(Error::Unauthorized(
+ "you are not authorized to edit this file".into(),
+ ctx,
+ ));
+ }
+ // Extract the `multipart/form-data` boundary from the headers.
+ let boundary = ctx
+ .parts
+ .headers
+ .get(header::CONTENT_TYPE)
+ .and_then(|ct| ct.to_str().ok())
+ .and_then(|ct| multer::parse_boundary(ct).ok())
+ .ok_or_else(|| Error::BadRequest("expected multipart/form-data".into()))?;
+
+ let mut multipart = Multipart::new(Box::new(body), boundary);
+ while let Some(field) = multipart
+ .next_field()
+ .await
+ .map_err(|_| Error::BadRequest("failed to parse multipart".into()))?
+ {
+ if field.name() == Some("file") {
+ // TODO: make commit message customizable
+ commit_file_update(&field.bytes().await.unwrap(), None, controller, &ctx)?;
+
+ controller.after_write(&mut ctx);
+
+ return Ok(Builder::new()
+ .status(StatusCode::FOUND)
+ .header("location", ctx.parts.uri.path())
+ .body("redirecting".into())
+ .unwrap());
+ }
+ }
+ Err(Error::BadRequest(
+ "expected file upload named 'file'".into(),
+ ))
+}
+
+fn commit_file_update<C: Controller>(
+ data: &[u8],
+ msg: Option<String>,
+ controller: &C,
+ ctx: &Context,
+) -> Result<(), Error> {
+ let blob_id = ctx.repo.blob(data)?;
+
+ let mut builder = TreeUpdateBuilder::new();
+
+ builder.upsert(ctx.path.to_str().unwrap(), blob_id, FileMode::Blob);
+
+ let (parent_tree, parent_commits) = if let Ok(commit) = ctx.branch_head() {
+ let parent_tree = commit.tree()?;
+ if parent_tree.get_path(&ctx.path).ok().map(|e| e.id()) == Some(blob_id) {
+ // nothing changed, don't create an empty commit
+ return Err(Error::Redirect(ctx.parts.uri.path().to_string()));
+ }
+ (parent_tree, vec![commit])
+ } else {
+ // the empty tree exists even in empty bare repositories
+ // we could also hard-code its hash here but magic strings are uncool
+ let empty_tree = ctx.repo.find_tree(ctx.repo.index()?.write_tree()?)?;
+ (empty_tree, vec![])
+ };
+
+ let new_tree_id = builder.create_updated(&ctx.repo, &parent_tree)?;
+
+ let signature = controller.signature(&ctx.repo, &ctx.parts)?;
+ ctx.commit(
+ &signature,
+ &msg.filter(|m| !m.trim().is_empty()).unwrap_or_else(|| {
+ format!(
+ "{} {}",
+ if parent_tree.get_path(&ctx.path).is_ok() {
+ "edit"
+ } else {
+ "create"
+ },
+ ctx.path.to_str().unwrap()
+ )
+ }),
+ &ctx.repo.find_tree(new_tree_id)?,
+ &parent_commits.iter().collect::<Vec<_>>()[..],
+ )?;
+
+ Ok(())
+}
+
+async fn preview_edit<C: Controller>(
+ body: Body,
+ controller: &C,
+ ctx: Context,
+) -> Result<Response, Error> {
+ let form: EditForm = body.into_form().await?;
+ let new_text = form.text.replace("\r\n", "\n");
+ let mut page = edit_text_form(&form, None, controller, &ctx);
+
+ page.body
+ .push_str(&(get_renderer(&ctx.path).unwrap()(&new_text)));
+ Ok(page.into())
+}
+
+async fn diff_blob<C: Controller>(
+ body: Body,
+ controller: &C,
+ ctx: Context,
+) -> Result<Response, Error> {
+ if !controller.may_write_path(&ctx) {
+ return Err(Error::Unauthorized(
+ "you are not authorized to edit this file".into(),
+ ctx,
+ ));
+ }
+
+ let form: EditForm = body.into_form().await?;
+ let new_text = form.text.replace("\r\n", "\n");
+
+ let entr = ctx.branch_head()?.tree().unwrap().get_path(&ctx.path)?;
+
+ let blob = ctx.repo.find_blob(entr.id()).unwrap();
+ let old_text = from_utf8(blob.content())?;
+
+ let mut page = edit_text_form(&form, None, controller, &ctx);
+ page.body.push_str(&diff(old_text, &new_text));
+ Ok(page.into())
+}
+
+fn diff(first: &str, second: &str) -> String {
+ if first == second {
+ return "<em>(no changes)</em>".into();
+ }
+
+ let Changeset { diffs, .. } = Changeset::new(&first, &second, "\n");
+
+ let mut output = String::new();
+
+ output.push_str("<pre>");
+
+ for i in 0..diffs.len() {
+ match diffs[i] {
+ Difference::Same(ref text) => {
+ let text = html_escape(text);
+ let lines: Vec<_> = text.split('\n').collect();
+ if i == 0 {
+ output.push_str(&lines[lines.len().saturating_sub(3)..].join("\n"));
+ } else if i == diffs.len() - 1 {
+ output.push_str(&lines[..cmp::min(3, lines.len())].join("\n"));
+ } else {
+ output.push_str(&text);
+ }
+ }
+ Difference::Add(ref text) => {
+ output.push_str("<div class=addition>");
+ if i == 0 {
+ output.push_str(&html_escape(text).replace("\n", "<br>"));
+ } else {
+ match diffs.get(i - 1) {
+ Some(Difference::Rem(ref rem)) => {
+ word_diff(&mut output, rem, text, "ins");
+ }
+ _ => {
+ output.push_str(&html_escape(text).replace("\n", "<br>"));
+ }
+ }
+ }
+ output.push_str("\n</div>");
+ }
+ Difference::Rem(ref text) => {
+ output.push_str("<div class=deletion>");
+ match diffs.get(i + 1) {
+ Some(Difference::Add(ref add)) => {
+ word_diff(&mut output, add, text, "del");
+ }
+ _ => {
+ output.push_str(&html_escape(text).replace("\n", "<br>"));
+ }
+ }
+ output.push_str("\n</div>");
+ }
+ }
+ }
+
+ output.push_str("</pre>");
+ output
+}
+
+fn word_diff(out: &mut String, text1: &str, text2: &str, tagname: &str) {
+ let Changeset { diffs, .. } = Changeset::new(text1, text2, " ");
+ for c in diffs {
+ match c {
+ Difference::Same(ref z) => {
+ out.push_str(&html_escape(z).replace("\n", "<br>"));
+ out.push(' ');
+ }
+ Difference::Add(ref z) => {
+ write!(
+ out,
+ "<{0}>{1}</{0}> ",
+ tagname,
+ html_escape(z).replace("\n", "<br>")
+ )
+ .expect("write error");
+ }
+ _ => {}
+ }
+ }
+}
+
+#[derive(Deserialize, Default)]
+struct EditForm {
+ text: String,
+ msg: Option<String>,
+ oid: Option<String>,
+}
+
+async fn update_blob<C: Controller>(
+ body: Body,
+ controller: &C,
+ mut ctx: Context,
+) -> Result<Response, Error> {
+ if !controller.may_write_path(&ctx) {
+ return Err(Error::Unauthorized(
+ "you are not authorized to edit this file".into(),
+ ctx,
+ ));
+ }
+ let mut data: EditForm = body.into_form().await?;
+
+ if let Ok(commit) = ctx.branch_head() {
+ // edit conflict detection
+ let latest_oid = commit
+ .tree()
+ .unwrap()
+ .get_path(&ctx.path)
+ .ok()
+ .map(|e| e.id().to_string());
+
+ if data.oid != latest_oid {
+ data.oid = latest_oid;
+ return Ok(edit_text_form(&data, Some(
+ if data.oid.is_some() {
+ "this file has been edited in the meantime, if you save you will overwrite the changes made since"
+ } else {
+ "this file has been deleted in the meantime, if you save you will re-create it"
+ }
+ ), controller, &ctx).into());
+ }
+ }
+
+ // normalize newlines as per HTML spec
+ let text = data.text.replace("\r\n", "\n");
+ if let Err(error) = controller.before_write(&text, &mut ctx) {
+ return Ok(edit_text_form(&data, Some(&error), controller, &ctx).into());
+ }
+
+ commit_file_update(text.as_bytes(), data.msg, controller, &ctx)?;
+
+ controller.after_write(&mut ctx);
+
+ return Ok(Builder::new()
+ .status(StatusCode::FOUND)
+ .header("location", ctx.parts.uri.path())
+ .body("redirecting".into())
+ .unwrap());
+}
+
+#[derive(Deserialize)]
+struct MoveForm {
+ dest: String,
+ msg: Option<String>,
+}
+
+async fn move_entry<C: Controller>(
+ body: Body,
+ controller: &C,
+ ctx: Context,
+) -> Result<Response, Error> {
+ if !controller.may_move_path(&ctx) {
+ return Err(Error::Unauthorized(
+ "you are not authorized to move this file".into(),
+ ctx,
+ ));
+ }
+ let mut data: MoveForm = body.into_form().await?;
+ let filename = ctx.path.file_name().unwrap().to_str().unwrap();
+
+ if ctx.path == Path::new(&data.dest) {
+ return move_form(
+ filename,
+ &data,
+ Some("can not move entry to itself"),
+ controller,
+ &ctx,
+ );
+ }
+
+ let parent_commit = ctx.branch_head()?;
+ let parent_tree = parent_commit.tree().unwrap();
+ if parent_tree.get_path(Path::new(&data.dest)).is_ok() {
+ return move_form(
+ filename,
+ &data,
+ Some("destination already exists"),
+ controller,
+ &ctx,
+ );
+ }
+
+ let mut builder = TreeUpdateBuilder::new();
+ let entr = parent_tree.get_path(&ctx.path)?;
+ builder.remove(&ctx.path);
+ builder.upsert(
+ &data.dest,
+ entr.id(),
+ match entr.filemode() {
+ 0o000000 => FileMode::Unreadable,
+ 0o040000 => FileMode::Tree,
+ 0o100644 => FileMode::Blob,
+ 0o100755 => FileMode::BlobExecutable,
+ 0o120000 => FileMode::Link,
+ _ => {
+ panic!("unexpected mode")
+ }
+ },
+ );
+
+ let new_tree_id = builder.create_updated(&ctx.repo, &parent_commit.tree()?)?;
+
+ ctx.commit(
+ &controller.signature(&ctx.repo, &ctx.parts)?,
+ &data
+ .msg
+ .take()
+ .filter(|m| !m.trim().is_empty())
+ .unwrap_or_else(|| format!("move {} to {}", ctx.path.to_str().unwrap(), data.dest)),
+ &ctx.repo.find_tree(new_tree_id)?,
+ &[&parent_commit],
+ )?;
+
+ Ok(Builder::new()
+ .status(StatusCode::FOUND)
+ .header("location", build_url_path(&ctx.branch, &data.dest))
+ .body("redirecting".into())
+ .unwrap())
+}
+
+#[derive(Deserialize)]
+struct RemoveForm {
+ msg: Option<String>,
+}
+
+async fn remove_entry<C: Controller>(
+ body: Body,
+ controller: &C,
+ ctx: Context,
+) -> Result<Response, Error> {
+ if !controller.may_move_path(&ctx) {
+ return Err(Error::Unauthorized(
+ "you are not authorized to remove this file".into(),
+ ctx,
+ ));
+ }
+ let data: RemoveForm = body.into_form().await?;
+ let mut builder = TreeUpdateBuilder::new();
+ builder.remove(&ctx.path);
+ let parent_commit = ctx.branch_head()?;
+ let new_tree_id = builder.create_updated(&ctx.repo, &parent_commit.tree()?)?;
+
+ ctx.commit(
+ &controller.signature(&ctx.repo, &ctx.parts)?,
+ &data
+ .msg
+ .filter(|m| !m.trim().is_empty())
+ .unwrap_or_else(|| format!("remove {}", ctx.path.to_str().unwrap())),
+ &ctx.repo.find_tree(new_tree_id)?,
+ &[&parent_commit],
+ )?;
+ Ok(Builder::new()
+ .status(StatusCode::FOUND)
+ .header(
+ "location",
+ build_url_path(&ctx.branch, ctx.path.parent().unwrap().to_str().unwrap()),
+ )
+ .body("redirecting".into())
+ .unwrap())
+}
+
+fn render_error(message: &str) -> String {
+ format!("<div class=error>error: {}</div>", html_escape(message))
+}
+
+pub struct Context {
+ repo: Repository,
+ parts: Parts,
+ branch: Branch,
+ path: PathBuf,
+}
+
+impl Context {
+ fn branch_head(&self) -> Result<Commit, Error> {
+ self.repo
+ .revparse_single(&self.branch.rev_str())
+ .map_err(|_| Error::NotFound("branch not found".into()))?
+ .into_commit()
+ .map_err(|_| Error::NotFound("branch not found".into()))
+ }
+
+ fn commit(
+ &self,
+ signature: &Signature,
+ msg: &str,
+ tree: &Tree,
+ parent_commits: &[&Commit],
+ ) -> Result<Oid, git2::Error> {
+ self.repo.commit(
+ Some(&self.branch.rev_str()),
+ signature,
+ signature,
+ msg,
+ tree,
+ parent_commits,
+ )
+ }
+}
+
+fn upload_form<'a, C: Controller>(
+ file_exists: bool,
+ controller: &'a C,
+ ctx: &'a Context,
+) -> Page<'a> {
+ let filename = ctx.path.file_name().unwrap().to_str().unwrap();
+ Page {
+ title: format!("Uploading {}", filename),
+ // TODO: add input for commit message
+ body: "<form action=?action=upload method=post enctype='multipart/form-data'>\
+ <input name=file type=file>\
+ <button>Upload</button>\
+ </form>"
+ .into(),
+ header: file_exists.then(|| action_links("edit", controller, &ctx)),
+ controller,
+ parts: &ctx.parts,
+ }
+}
+
+fn render_markdown(input: &str) -> String {
+ let parser = Parser::new_ext(input, Options::all());
+ let mut out = String::new();
+ out.push_str("<div class=markdown-output>");
+ html::push_html(&mut out, parser);
+ out.push_str("</div>");
+ out
+}
+
+fn get_renderer(path: &Path) -> Option<fn(&str) -> String> {
+ match path.extension().map(|e| e.to_str().unwrap()) {
+ Some("md") => Some(render_markdown),
+ _ => None,
+ }
+}
+
+fn edit_text_form<'a, C: Controller>(
+ data: &EditForm,
+ error: Option<&str>,
+ controller: &'a C,
+ ctx: &'a Context,
+) -> Page<'a> {
+ let mut page = Page {
+ title: format!(
+ "{} {}",
+ if data.oid.is_some() {
+ "Editing"
+ } else {
+ "Creating"
+ },
+ ctx.path.file_name().unwrap().to_str().unwrap()
+ ),
+ header: data
+ .oid
+ .is_some()
+ .then(|| action_links("edit", controller, ctx)),
+ body: String::new(),
+ controller,
+ parts: &ctx.parts,
+ };
+ if let Some(access_info_html) = controller.access_info_html(&ctx) {
+ page.body.push_str(&access_info_html);
+ }
+ if let Some(hint_html) = controller.edit_hint_html(ctx) {
+ page.body
+ .push_str(&format!("<div class=edit-hint>{}</div>", hint_html));
+ }
+ if let Some(error) = error {
+ page.body.push_str(&render_error(error));
+ }
+ page.body.push_str(&format!(
+ "<form method=post action='?action=edit' class=edit-form>\
+ <textarea name=text autofocus autocomplete=off>{}</textarea>",
+ html_escape(&data.text)
+ ));
+ page.body
+ .push_str("<div class=buttons><button>Save</button>");
+ if let Some(oid) = &data.oid {
+ page.body.push_str(&format!(
+ "<input name=oid type=hidden value='{}'>
+ <button formaction='?action=diff'>Diff</button>",
+ oid
+ ));
+ }
+ if get_renderer(&ctx.path).is_some() {
+ page.body
+ .push_str(" <button formaction='?action=preview'>Preview</button>")
+ }
+ page.body.push_str(&format!(
+ "<input name=msg placeholder=Message value='{}' autocomplete=off></div></form>",
+ html_escape(data.msg.as_deref().unwrap_or_default())
+ ));
+
+ page.body.push_str(&format!(
+ "<script>{}</script>",
+ include_str!("static/edit_script.js")
+ ));
+ page
+}
+
+fn move_form<C: Controller>(
+ filename: &str,
+ data: &MoveForm,
+ error: Option<&str>,
+ controller: &C,
+ ctx: &Context,
+) -> Result<Response, Error> {
+ let mut page = Page {
+ title: format!("Move {}", filename),
+ controller,
+ parts: &ctx.parts,
+ body: String::new(),
+ header: Some(action_links("move", controller, ctx)),
+ };
+
+ if let Some(error) = error {
+ page.body.push_str(&render_error(error));
+ }
+
+ page.body.push_str(&format!(
+ "<form method=post autocomplete=off>
+ <label>Destination <input name=dest value='{}' autofocus></label>
+ <label>Message <input name=msg value='{}'></label>
+ <button>Move</button>
+ </form>",
+ html_escape(&data.dest),
+ data.msg.as_ref().map(html_escape).unwrap_or_default(),
+ ));
+
+ Ok(page.into())
+}
+
+#[derive(Deserialize)]
+struct LogParam {
+ commit: Option<String>,
+}
+
+fn view_blob<C: Controller>(
+ entr: TreeEntry,
+ params: ActionParam,
+ controller: &C,
+ ctx: Context,
+) -> Result<Response, Error> {
+ let filename = ctx.path.file_name().unwrap().to_str().unwrap();
+
+ match params.action.as_ref() {
+ "view" => {
+ let mut page = Page {
+ title: filename.to_string(),
+ body: String::new(),
+ header: Some(action_links(&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())
+}