aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/controller.rs657
-rw-r--r--src/help/shares.txt.html9
-rw-r--r--src/help/users.toml.html8
-rw-r--r--src/main.rs1465
-rw-r--r--src/static/edit_script.js15
-rw-r--r--src/static/edit_script.js.sha2561
-rw-r--r--src/static/style.css83
-rw-r--r--src/static/style.css.sha2561
-rwxr-xr-xsrc/static/update_hashes.sh5
9 files changed, 2244 insertions, 0 deletions
diff --git a/src/controller.rs b/src/controller.rs
new file mode 100644
index 0000000..2eab998
--- /dev/null
+++ b/src/controller.rs
@@ -0,0 +1,657 @@
+use std::collections::HashMap;
+use std::path::Component;
+use std::path::Path;
+use std::path::PathBuf;
+use std::str::from_utf8;
+use std::sync::RwLock;
+
+use git2::ObjectType;
+use git2::Repository;
+use git2::Signature;
+use git2::Tree;
+use hyper::http::request::Parts;
+use serde::Deserialize;
+use sputnik::html_escape;
+
+use crate::Branch;
+use crate::Context;
+use crate::Error;
+use crate::Page;
+use crate::Response;
+
+pub trait Controller {
+ /// Allows the controller to abort if the request is invalid.
+ fn before_route(&self, parts: &Parts) -> Result<(), Error>;
+
+ /// Returns some HTML info to display for the current page.
+ fn user_info_html(&self, parts: &Parts) -> Option<String> {
+ None
+ }
+
+ /// Returns an HTML string describing who has access to the context.
+ fn access_info_html(&self, ctx: &Context) -> Option<String> {
+ None
+ }
+
+ /// Returns the author/committer signature used to create commits.
+ fn signature(&self, repo: &Repository, parts: &Parts) -> Result<Signature, Error>;
+
+ /// Returns whether or not a request is authorized to read a file or list a directory.
+ fn may_read_path(&self, context: &Context) -> bool;
+
+ /// Returns whether or not a request is authorized to write a file.
+ fn may_write_path(&self, context: &Context) -> bool;
+
+ /// Returns whether or not a request is authorized to (re)move a file.
+ fn may_move_path(&self, context: &Context) -> bool;
+
+ fn edit_hint_html(&self, context: &Context) -> Option<String> {
+ None
+ }
+
+ fn before_return_tree_page(&self, page: &mut Page, tree: Option<Tree>, context: &Context) {}
+
+ /// Executed before writing a file. Return an error to abort the writing process.
+ fn before_write(&self, text: &str, context: &mut Context) -> Result<(), String> {
+ Ok(())
+ }
+
+ /// Executed after successfully writing a file.
+ fn after_write(&self, context: &mut Context) {}
+
+ /// Lets the controller optionally intercept error responses.
+ fn before_return_error(&self, error: &Error) -> Option<Response> {
+ None
+ }
+}
+
+pub struct SoloController;
+
+const USERNAME_HEADER: &str = "Username";
+
+impl Controller for SoloController {
+ fn before_route(&self, parts: &Parts) -> Result<(), Error> {
+ if parts.headers.contains_key(USERNAME_HEADER) {
+ return Err(Error::BadRequest(format!(
+ "unexpected header {} (only \
+ allowed in multi-user mode), aborting to prevent accidental \
+ information leakage",
+ USERNAME_HEADER
+ )));
+ }
+ Ok(())
+ }
+
+ fn signature(&self, repo: &Repository, parts: &Parts) -> Result<Signature, Error> {
+ repo.signature().map_err(|e| Error::Internal(e.to_string()))
+ }
+
+ fn may_read_path(&self, _context: &Context) -> bool {
+ true
+ }
+
+ fn may_write_path(&self, _context: &Context) -> bool {
+ true
+ }
+
+ fn may_move_path(&self, _context: &Context) -> bool {
+ true
+ }
+
+ fn before_write(&self, text: &str, ctx: &mut Context) -> Result<(), String> {
+ if let Some(ext) = ctx.path.extension().and_then(|e| e.to_str()) {
+ validate_formats(text, ext)?;
+ }
+ Ok(())
+ }
+}
+
+#[derive(Deserialize)]
+#[serde(transparent)]
+struct Identities(HashMap<String, Identity>);
+
+pub struct MultiUserController {
+ identities: RwLock<Identities>,
+ shares_cache: RwLock<HashMap<Branch, Shares>>,
+}
+
+/// Maps paths to access rules.
+#[derive(Default, Debug)]
+pub struct Shares {
+ exact_rules: HashMap<String, AccessRuleset>,
+ prefix_rules: HashMap<String, AccessRuleset>,
+}
+
+/// Maps usernames to access modes and .shares.txt source line.
+#[derive(Default, Debug)]
+struct AccessRuleset(HashMap<String, AccessRule>);
+
+#[derive(Debug)]
+struct AccessRule {
+ mode: AccessMode,
+ line: usize,
+ start: usize,
+ end: usize,
+}
+
+#[derive(PartialEq, Debug)]
+enum AccessMode {
+ ReadAndWrite,
+ ReadOnly,
+}
+
+// using our own struct because git2::Signature isn't thread-safe
+#[derive(Deserialize)]
+struct Identity {
+ name: String,
+ email: String,
+}
+
+impl MultiUserController {
+ pub fn new(repo: &Repository) -> Self {
+ let identities: Option<Identities> = try {
+ let rev = repo.revparse_single("refs/heads/gitpad").ok()?;
+ let commit = rev.into_commit().ok()?;
+ let tree = commit.tree().ok()?;
+ let entry = tree.get_path(Path::new("users.toml")).ok()?;
+ let blob = repo.find_blob(entry.id()).ok()?;
+ toml::from_slice(blob.content()).ok()?
+ };
+
+ Self {
+ identities: RwLock::new(identities.unwrap_or_else(|| Identities(HashMap::new()))),
+ shares_cache: RwLock::new(HashMap::new()),
+ }
+ }
+}
+
+#[derive(Debug)]
+enum RulesIterState {
+ Exact,
+ Prefix(usize),
+ Done,
+}
+
+#[derive(Debug)]
+struct RulesIter<'a> {
+ shares: &'a Shares,
+ path: &'a str,
+ state: RulesIterState,
+}
+
+impl<'a> Iterator for RulesIter<'a> {
+ type Item = &'a AccessRuleset;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ match self.state {
+ RulesIterState::Exact => {
+ self.state = RulesIterState::Prefix(self.path.len());
+ self.shares
+ .exact_rules
+ .get(self.path)
+ .or_else(|| self.next())
+ }
+ RulesIterState::Prefix(idx) => {
+ let path = &self.path[..idx];
+ if let Some(new_idx) = path.rfind('/') {
+ self.state = RulesIterState::Prefix(new_idx);
+ } else {
+ self.state = RulesIterState::Done;
+ }
+ self.shares.prefix_rules.get(path).or_else(|| self.next())
+ }
+ RulesIterState::Done => None,
+ }
+ }
+}
+
+impl Shares {
+ fn rules_iter<'a>(&'a self, path: &'a str) -> RulesIter {
+ RulesIter {
+ shares: self,
+ path,
+ state: RulesIterState::Exact,
+ }
+ }
+
+ fn find_mode<'a>(&'a self, path: &'a str, username: &str) -> Option<&AccessMode> {
+ for ruleset in self.rules_iter(path) {
+ if let Some(rule) = ruleset.0.get(username) {
+ return Some(&rule.mode);
+ }
+ }
+ None
+ }
+}
+
+fn parse_shares_txt(text: &str) -> Result<Shares, String> {
+ let mut exact_rules: HashMap<String, AccessRuleset> = HashMap::new();
+ let mut prefix_rules: HashMap<String, AccessRuleset> = HashMap::new();
+
+ let mut start;
+ let mut next = 0;
+
+ for (idx, line) in text.lines().enumerate() {
+ start = next;
+ next = start + line.chars().count() + 1;
+
+ if line.starts_with('#') || line.trim_end().is_empty() {
+ continue;
+ }
+
+ let mut iter = line.split('#').next().unwrap().split_ascii_whitespace();
+ let permissions = iter.next().unwrap();
+ let path = iter
+ .next()
+ .ok_or_else(|| format!("line #{} expected three values", idx + 1))?;
+ let username = iter
+ .next()
+ .ok_or_else(|| format!("line #{} expected three values", idx + 1))?
+ .trim_end();
+ if iter.next().is_some() {
+ return Err(format!(
+ "line #{} unexpected fourth argument (paths with spaces are unsupported)",
+ idx + 1
+ ));
+ }
+
+ if permissions != "r" && permissions != "w" {
+ return Err(format!("line #{} must start with r or w", idx + 1));
+ }
+ if username.is_empty() {
+ return Err(format!("line #{} empty username", idx + 1));
+ }
+ if path.is_empty() {
+ return Err(format!("line #{} empty path", idx + 1));
+ }
+ if let (Some(first_ast), Some(last_ast)) = (path.find('*'), path.rfind('*')) {
+ if first_ast != last_ast || !path.ends_with("/*") {
+ return Err(format!(
+ "line #{} wildcards in paths may only occur at the very end as /*",
+ idx + 1
+ ));
+ }
+ }
+ let rule = AccessRule {
+ mode: if permissions == "w" {
+ AccessMode::ReadAndWrite
+ } else {
+ AccessMode::ReadOnly
+ },
+ line: idx + 1,
+ start,
+ end: next,
+ };
+
+ // normalize path
+ let mut comps = Vec::new();
+
+ for comp in Path::new(&*path).components() {
+ match comp {
+ Component::Normal(name) => comps.push(name),
+ Component::ParentDir => {
+ return Err(format!("line #{}: path may not contain ../", idx + 1))
+ }
+ _ => {}
+ }
+ }
+
+ let path: PathBuf = comps.iter().collect();
+ let path = path.to_str().unwrap();
+
+ if let Some(stripped) = path.strip_suffix("/*") {
+ let pr = prefix_rules.entry(stripped.to_owned()).or_default();
+ if let Some(prevrule) = pr.0.get(username) {
+ if rule.mode != prevrule.mode {
+ return Err(format!(
+ "line #{} conflicts with line #{} ({} {})",
+ idx + 1,
+ line,
+ username,
+ path
+ ));
+ }
+ }
+ pr.0.insert(username.to_string(), rule);
+ } else {
+ let ac = exact_rules.entry(path.to_owned()).or_default();
+ if let Some(prevrule) = ac.0.get(username) {
+ if rule.mode != prevrule.mode {
+ return Err(format!(
+ "line #{} conflicts with line #{} ({} {})",
+ idx + 1,
+ line,
+ username,
+ path
+ ));
+ }
+ }
+ ac.0.insert(username.to_string(), rule);
+ }
+ }
+ Ok(Shares {
+ exact_rules,
+ prefix_rules,
+ })
+}
+
+impl MultiUserController {
+ fn with_shares_cache<F: FnMut(&Shares)>(
+ &self,
+ repo: &Repository,
+ rev: Branch,
+ mut callback: F,
+ ) {
+ let mut cache = self.shares_cache.write().unwrap();
+ let entry = cache.entry(rev.clone()).or_insert_with(|| {
+ if let Ok(entry) = repo
+ .revparse_single(&rev.rev_str())
+ .map(|r| r.into_commit().unwrap().tree().unwrap())
+ .and_then(|t| t.get_path(Path::new(".shares.txt")))
+ {
+ if entry.kind().unwrap() == ObjectType::Blob {
+ if let Ok(text) = from_utf8(repo.find_blob(entry.id()).unwrap().content()) {
+ if let Ok(shares) = parse_shares_txt(text) {
+ return shares;
+ }
+ }
+ }
+ }
+ Shares::default()
+ });
+ callback(entry);
+ }
+
+ fn list_shares(&self, context: &Context, username: &str, out: &mut String) {
+ self.with_shares_cache(&context.repo, context.branch.clone(), |shares| {
+ out.push_str("<a href=..>../</a>");
+ let exact_shares: Vec<_> = shares
+ .exact_rules
+ .iter()
+ .filter_map(|(path, rules)| rules.0.get(username).map(|rule| (path, &rule.mode)))
+ .collect();
+ let prefix_shares: Vec<_> = shares
+ .prefix_rules
+ .iter()
+ .filter_map(|(path, rules)| rules.0.get(username).map(|rule| (path, &rule.mode)))
+ .collect();
+ if exact_shares.is_empty() && prefix_shares.is_empty() {
+ out.push_str(&format!(
+ "{} hasn't shared any files with you.",
+ context.branch.0
+ ));
+ } else {
+ out.push_str(&format!(
+ "{} has shared the following files with you:",
+ context.branch.0
+ ));
+ // TODO: display modes?
+ out.push_str("<ul>");
+ for (path, mode) in exact_shares {
+ out.push_str(&format!(
+ "<li><a href='{0}'>{0}</a></li>",
+ html_escape(path)
+ ));
+ }
+ for (path, mode) in prefix_shares {
+ out.push_str(&format!(
+ "<li><a href='{0}/'>{0}</a>/*</li>",
+ html_escape(path)
+ ));
+ }
+ out.push_str("</ul>");
+ }
+ });
+ }
+}
+
+const EMPTY_HOME_HINT: &str = "This is your home directory. Create files by editing the URL.";
+
+fn link_share(own_username: &str, (username, rule): (&&String, &&AccessRule)) -> String {
+ format!(
+ "<a href='/~{}/.shares.txt?action=edit#L{}-L{}'>{}</a>",
+ html_escape(own_username),
+ rule.start,
+ rule.end,
+ html_escape(*username),
+ )
+}
+
+fn join(len: usize, iter: &mut dyn Iterator<Item = String>) -> String {
+ let mut out = String::new();
+ out.push_str(&iter.next().unwrap());
+ for i in 1..len - 1 {
+ out.push_str(", ");
+ out.push_str(&iter.next().unwrap());
+ }
+ if len > 1 {
+ out.push_str(&format!(" &amp; {}", iter.next().unwrap()));
+ }
+ out
+}
+
+fn username_from_parts(parts: &Parts) -> Option<&str> {
+ parts
+ .headers
+ .get(USERNAME_HEADER)
+ .and_then(|h| h.to_str().ok())
+}
+
+fn validate_formats(text: &str, extension: &str) -> Result<(), String> {
+ if extension == "toml" {
+ toml::from_str::<toml::Value>(text).map_err(|e| e.to_string())?;
+ }
+ Ok(())
+}
+
+impl Controller for MultiUserController {
+ fn before_route(&self, parts: &Parts) -> Result<(), Error> {
+ if !parts.headers.contains_key(USERNAME_HEADER) {
+ return Err(Error::BadRequest(format!(
+ "expected header {} because of multi-user mode \
+ (this shouldn't be happening because a reverse-proxy \
+ should be used to set this header)",
+ USERNAME_HEADER
+ )));
+ }
+ Ok(())
+ }
+
+ fn user_info_html(&self, parts: &Parts) -> Option<String> {
+ let username = username_from_parts(parts).unwrap();
+ Some(format!(
+ "<a href='/~{0}/' title='your home directory'>~{0}</a>",
+ html_escape(username)
+ ))
+ }
+
+ fn before_return_tree_page(&self, page: &mut Page, tree: Option<Tree>, context: &Context) {
+ let username = username_from_parts(&context.parts).unwrap();
+ if context.path.components().count() == 0 {
+ if context.branch.0 == username {
+ match tree {
+ None => page.body.push_str(EMPTY_HOME_HINT),
+ Some(tree) => {
+ if tree.iter().count() == 0 {
+ page.body.push_str(EMPTY_HOME_HINT);
+ } else if tree.get_path(Path::new(".shares.txt")).is_err() {
+ page.body.push_str("<p>Share files with other users by <a href='.shares.txt?action=edit'>creating a .shares.txt</a> config.</p>");
+ }
+ }
+ }
+ } else {
+ self.list_shares(context, username, &mut page.body);
+ }
+ }
+ }
+
+ fn before_return_error(&self, error: &Error) -> Option<Response> {
+ if let Error::Unauthorized(_, context) = error {
+ let username = username_from_parts(&context.parts).unwrap();
+ if context.path.components().count() == 0 {
+ let mut page = Page {
+ title: "".into(),
+ header: None,
+ body: String::new(),
+ controller: self,
+ parts: &context.parts,
+ };
+
+ self.list_shares(context, username, &mut page.body);
+ return Some(page.into());
+ }
+ }
+ None
+ }
+
+ fn signature(&self, _repo: &Repository, parts: &Parts) -> Result<Signature, Error> {
+ // TODO: return proper error message if header missing
+ let username = username_from_parts(parts).unwrap();
+ if let Some(identity) = self.identities.read().unwrap().0.get(username) {
+ Signature::now(identity.name.as_str(), &identity.email)
+ .map_err(|e| e.to_string())
+ .map_err(Error::Internal)
+ } else {
+ Signature::now(username, &format!("{}@localhost.invalid", username))
+ .map_err(|e| e.to_string())
+ .map_err(Error::Internal)
+ }
+ }
+
+ fn may_read_path(&self, ctx: &Context) -> bool {
+ let username = username_from_parts(&ctx.parts).unwrap();
+ if ctx.branch.0 == username {
+ return true;
+ }
+
+ let mut ok = false;
+ self.with_shares_cache(&ctx.repo, ctx.branch.clone(), |shares| {
+ if let Some(mode) = shares.find_mode(ctx.path.to_str().unwrap(), username) {
+ ok = true;
+ }
+ });
+ ok
+ }
+
+ fn may_write_path(&self, ctx: &Context) -> bool {
+ let username = username_from_parts(&ctx.parts).unwrap();
+ if ctx.branch.0 == username {
+ return true;
+ }
+
+ let mut ok = false;
+ self.with_shares_cache(&ctx.repo, ctx.branch.clone(), |shares| {
+ if let Some(AccessMode::ReadAndWrite) =
+ shares.find_mode(ctx.path.to_str().unwrap(), username)
+ {
+ ok = true;
+ }
+ });
+ ok
+ }
+
+ fn may_move_path(&self, ctx: &Context) -> bool {
+ ctx.branch.0 == username_from_parts(&ctx.parts).unwrap()
+ }
+
+ fn edit_hint_html(&self, ctx: &Context) -> Option<String> {
+ match (ctx.branch.0.as_str(), ctx.path.to_str().unwrap()) {
+ (_, ".shares.txt") => {
+ return Some(include_str!("help/shares.txt.html").into());
+ }
+ ("gitpad", "users.toml") => {
+ return Some(include_str!("help/users.toml.html").into());
+ }
+ _ => {}
+ }
+ None
+ }
+
+ fn before_write(&self, text: &str, ctx: &mut Context) -> Result<(), String> {
+ match (ctx.branch.0.as_str(), ctx.path.to_str().unwrap()) {
+ (_, ".shares.txt") => {
+ ctx.parts.extensions.insert(parse_shares_txt(text)?);
+ }
+ ("gitpad", "users.toml") => {
+ ctx.parts
+ .extensions
+ .insert(toml::from_str::<Identities>(text).map_err(|e| e.to_string())?);
+ }
+ _ => {
+ if let Some(ext) = ctx.path.extension().and_then(|e| e.to_str()) {
+ validate_formats(text, ext)?;
+ }
+ }
+ }
+ Ok(())
+ }
+
+ fn after_write(&self, ctx: &mut Context) {
+ match (ctx.branch.0.as_str(), ctx.path.to_str().unwrap()) {
+ (_, ".shares.txt") => {
+ self.shares_cache
+ .write()
+ .unwrap()
+ .insert(ctx.branch.clone(), ctx.parts.extensions.remove().unwrap());
+ }
+ ("gitpad", "users.toml") => {
+ *self.identities.write().unwrap() = ctx.parts.extensions.remove().unwrap();
+ }
+ _ => {}
+ }
+ }
+
+ fn access_info_html(&self, ctx: &Context) -> Option<String> {
+ let own_username = username_from_parts(&ctx.parts).unwrap();
+ if own_username != ctx.branch.0 {
+ return None;
+ }
+ let path = ctx.path.to_str().unwrap();
+ let mut result = None;
+ self.with_shares_cache(&ctx.repo, ctx.branch.clone(), |shares| {
+ let mut users = HashMap::new();
+ for rules in shares.rules_iter(path) {
+ for (username, rule) in &rules.0 {
+ if !users.contains_key(username) {
+ users.insert(username, rule);
+ }
+ }
+ }
+
+ if !users.is_empty() {
+ let (mut writers, mut readers): (Vec<_>, Vec<_>) = users
+ .iter()
+ .partition(|(_name, rule)| rule.mode == AccessMode::ReadAndWrite);
+ writers.sort_by(|a, b| a.0.cmp(b.0));
+ readers.sort_by(|a, b| a.0.cmp(b.0));
+
+ let mut out = String::new();
+ out.push_str("<div class=note>");
+ if !writers.is_empty() {
+ out.push_str(&format!(
+ "writable for {}",
+ join(
+ writers.len(),
+ &mut writers.iter().map(|r| link_share(own_username, *r))
+ )
+ ));
+ }
+ if !readers.is_empty() {
+ if !writers.is_empty() {
+ out.push_str(", ");
+ }
+ out.push_str(&format!(
+ "readable for {}",
+ join(
+ readers.len(),
+ &mut readers.iter().map(|r| link_share(own_username, *r))
+ )
+ ));
+ }
+ out.push_str("</div>");
+ result = Some(out)
+ }
+ });
+ result
+ }
+}
diff --git a/src/help/shares.txt.html b/src/help/shares.txt.html
new file mode 100644
index 0000000..be5f4c5
--- /dev/null
+++ b/src/help/shares.txt.html
@@ -0,0 +1,9 @@
+This configuration file lets you share files with other users.
+Lines need to be in one of the following two formats:
+
+<pre>
+r &lt;path> &lt;username> # grants read-only access
+w &lt;path> &lt;username> # grants read/write access
+</pre>
+
+Ending a path in <code>/*</code> makes the rule apply to all subfiles. \ No newline at end of file
diff --git a/src/help/users.toml.html b/src/help/users.toml.html
new file mode 100644
index 0000000..fe195e2
--- /dev/null
+++ b/src/help/users.toml.html
@@ -0,0 +1,8 @@
+This configuration file lets you configure
+committer identities for users. For example:
+
+<pre>
+[johndoe]
+name = "John Doe"
+email = "john@example.com"
+</pre> \ No newline at end of file
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())
+}
diff --git a/src/static/edit_script.js b/src/static/edit_script.js
new file mode 100644
index 0000000..a73898f
--- /dev/null
+++ b/src/static/edit_script.js
@@ -0,0 +1,15 @@
+const textarea = document.forms[0].text;
+textarea.addEventListener('keydown', (e) => {
+ if (e.key == 'Escape') {
+ e.preventDefault();
+ location.search = '';
+ }
+});
+if (location.hash) {
+ let match = location.hash.match(/L([0-9]+)-L([0-9]+)/);
+ if (match) {
+ textarea.setSelectionRange(match[1], match[2]);
+ }
+ // workaround for Chromium bug (https://crbug.com/1046357)
+ textarea.focus();
+} \ No newline at end of file
diff --git a/src/static/edit_script.js.sha256 b/src/static/edit_script.js.sha256
new file mode 100644
index 0000000..c03887e
--- /dev/null
+++ b/src/static/edit_script.js.sha256
@@ -0,0 +1 @@
+O/Q67ZO/c2t0OEnZQIJtx/2VGvvdDEBTB8ol44aaUIo= \ No newline at end of file
diff --git a/src/static/style.css b/src/static/style.css
new file mode 100644
index 0000000..15821e8
--- /dev/null
+++ b/src/static/style.css
@@ -0,0 +1,83 @@
+html, body {
+ height: 100%;
+ margin: 0;
+}
+
+body {
+ display: flex;
+ flex-direction: column;
+ box-sizing: border-box;
+ padding: 1em;
+ max-width: 800px;
+ margin: 0 auto;
+}
+
+.edit-form {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+}
+
+textarea {
+ flex-grow: 1;
+}
+
+.active {
+ font-weight: bold;
+}
+
+.actions {
+ margin-bottom: 0.2em;
+}
+
+.buttons {
+ margin-top: 0.2em;
+ display: flex;
+}
+
+.buttons button {
+ margin-right: 0.4em;
+}
+
+.buttons input {
+ flex-grow: 1;
+}
+
+.error {
+ padding: 0.2em;
+ margin: 0.5em 0;
+ background-color: #f8d7da;
+ border: 1px solid #f5c2c7;
+}
+
+.note {
+ padding: 0.2em;
+ margin: 0.5em 0;
+ background-color: #fff3cd;
+ border: 1px solid #ffecb5;
+}
+
+.edit-hint {
+ margin: 0.5em 0;
+}
+
+#header {
+ display: flex;
+}
+
+.user-info {
+ margin-left: auto;
+}
+
+label {
+ display: block;
+}
+
+.img-container img {
+ max-width: 100%;
+}
+
+.addition { background: #e6ffed; }
+.deletion { background: #ffeef0; }
+.addition ins {background: #acf2bd; text-decoration: none; }
+.deletion del {background: #fdb8c0; text-decoration: none; } \ No newline at end of file
diff --git a/src/static/style.css.sha256 b/src/static/style.css.sha256
new file mode 100644
index 0000000..878c14a
--- /dev/null
+++ b/src/static/style.css.sha256
@@ -0,0 +1 @@
+wQBINLe+9xuEst1p9wa95+08ECz1vGzc6bV960VQHDQ= \ No newline at end of file
diff --git a/src/static/update_hashes.sh b/src/static/update_hashes.sh
new file mode 100755
index 0000000..31f63bd
--- /dev/null
+++ b/src/static/update_hashes.sh
@@ -0,0 +1,5 @@
+#/bin/sh
+cd "$(dirname "$0")"
+for script in *.css *.js; do
+ shasum -a 256 < $script | cut -d' ' -f1 | xxd -r -p | base64 -w 0 > $script.sha256
+done