diff options
author | Martin Fischer <martin@push-f.com> | 2021-06-22 22:14:19 +0200 |
---|---|---|
committer | Martin Fischer <martin@push-f.com> | 2021-06-22 23:54:36 +0200 |
commit | d6d71d1693cb7d798bdf2b12c5fa6eea0a5860df (patch) | |
tree | 73e54182d77c4923f244b145b05c61ae67970d62 /src |
publish
Diffstat (limited to 'src')
-rw-r--r-- | src/controller.rs | 657 | ||||
-rw-r--r-- | src/help/shares.txt.html | 9 | ||||
-rw-r--r-- | src/help/users.toml.html | 8 | ||||
-rw-r--r-- | src/main.rs | 1465 | ||||
-rw-r--r-- | src/static/edit_script.js | 15 | ||||
-rw-r--r-- | src/static/edit_script.js.sha256 | 1 | ||||
-rw-r--r-- | src/static/style.css | 83 | ||||
-rw-r--r-- | src/static/style.css.sha256 | 1 | ||||
-rwxr-xr-x | src/static/update_hashes.sh | 5 |
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!(" & {}", 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 <path> <username> # grants read-only access +w <path> <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(¶ms.action, controller, &ctx)), + controller, + parts: &ctx.parts, + }; + + if let Some(access_info_html) = controller.access_info_html(&ctx) { + page.body.push_str(&access_info_html); + } + + if entr.filemode() == FileMode::Link.into() { + // TODO: indicate and link symbolic link + } + + let blob = ctx.repo.find_blob(entr.id()).unwrap(); + + if let Some(mime) = mime_guess::from_path(&ctx.path).first() { + if mime.type_() == "image" { + page.body + .push_str("<div class=img-container><img src=?action=raw></div>"); + return Ok(page.into()); + } + } + + match from_utf8(blob.content()) { + Ok(text) => { + if let Some(renderer) = get_renderer(&ctx.path) { + page.body.push_str(&renderer(text)); + } else { + page.body + .push_str(&format!("<pre>{}</pre>", html_escape(text))); + } + } + Err(_) => page.body.push_str("failed to decode file as UTF-8"), + } + + Ok(page.into()) + } + "edit" => { + if !controller.may_write_path(&ctx) { + return Err(Error::Unauthorized( + "you are not authorized to edit this file".into(), + ctx, + )); + } + let blob = ctx.repo.find_blob(entr.id()).unwrap(); + if let Ok(text) = from_utf8(blob.content()) { + return Ok(edit_text_form( + &EditForm { + text: text.to_string(), + oid: Some(entr.id().to_string()), + ..Default::default() + }, + None, + controller, + &ctx, + ) + .into()); + } else { + return Ok(upload_form(true, controller, &ctx).into()); + } + } + "upload" => { + return Ok(upload_form(true, controller, &ctx).into()); + } + "log" => { + let log_param: LogParam = ctx.parts.query().unwrap(); + + if let Some(commit) = log_param.commit { + let branch_commit = ctx.branch_head()?; + + let commit = ctx + .repo + .find_commit(Oid::from_str(&commit)?) + .map_err(|_| Error::NotFound("commit not found".into()))?; + + if branch_commit.id() != commit.id() + && !ctx + .repo + .graph_descendant_of(branch_commit.id(), commit.id())? + { + // disallow viewing commits from other branches you shouldn't have access to + return Err(Error::NotFound("commit not found".into())); + } + + let blob_id = if let Ok(entry) = commit.tree()?.get_path(&ctx.path) { + entry.id() + } else { + return Ok(Page { + title: format!("Commit for {}", filename), + body: "file removed".into(), + header: Some(action_links(¶ms.action, controller, &ctx)), + controller, + parts: &ctx.parts, + } + .into()); + }; + + // TODO: if UTF-8 decoding fails, link ?action=raw&rev= + // TODO: what if there are multiple parents? + let old_blob_id = commit + .parents() + .next() + .and_then(|p| p.tree().unwrap().get_path(&ctx.path).ok()) + .map(|e| e.id()); + if Some(blob_id) != old_blob_id { + let mut page = Page { + title: format!("Commit for {}", filename), + header: Some(action_links(¶ms.action, controller, &ctx)), + body: format!( + "<h1>{}</h1>{} committed on {}", + html_escape(commit.summary().unwrap_or_default()), + html_escape(commit.author().name().unwrap_or_default()), + NaiveDateTime::from_timestamp(commit.time().seconds(), 0) + .format("%b %d, %Y, %H:%M") + ), + controller, + parts: &ctx.parts, + }; + if let Some(old_blob_id) = old_blob_id { + page.body.push_str(&diff( + from_utf8(ctx.repo.find_blob(old_blob_id)?.content())?, + from_utf8(ctx.repo.find_blob(blob_id)?.content())?, + )) + } else { + page.body.push_str(&diff( + "", + from_utf8(&ctx.repo.find_blob(blob_id)?.content())?, + )); + } + return Ok(page.into()); + } else { + return Err(Error::NotFound("commit not found".into())); + } + } + + let mut page = Page { + title: format!("Log for {}", filename), + body: String::new(), + header: Some(action_links(¶ms.action, controller, &ctx)), + controller, + parts: &ctx.parts, + }; + + let mut walk = ctx.repo.revwalk()?; + let branch_head = ctx.branch_head()?; + walk.push(branch_head.id())?; + + let mut prev_commit = branch_head; + let mut prev_blobid = Some(prev_commit.tree()?.get_path(&ctx.path)?.id()); + + let mut commits = Vec::new(); + + // TODO: paginate + for oid in walk.flatten().skip(1) { + let commit = ctx.repo.find_commit(oid)?; + if let Ok(entr) = commit.tree()?.get_path(&ctx.path) { + let blobid = entr.id(); + if Some(blobid) != prev_blobid { + commits.push(prev_commit); + prev_blobid = Some(blobid); + } + prev_commit = commit; + } else { + if prev_blobid.is_some() { + commits.push(prev_commit); + } + prev_commit = commit; + prev_blobid = None; + } + } + if prev_commit.parent_count() == 0 && prev_commit.tree()?.get_path(&ctx.path).is_ok() { + // the very first commit of the branch + commits.push(prev_commit); + } + let mut prev_date = None; + for c in commits { + let date = NaiveDateTime::from_timestamp(c.time().seconds(), 0).date(); + if Some(date) != prev_date { + if prev_date != None { + page.body.push_str("</ul>"); + } + page.body + .push_str(&format!("{}<ul>", date.format("%b %d, %Y"))); + } + + page.body.push_str(&format!( + "<li><a href='?action=log&commit={}'>{}: {}</a></li>", + html_escape(c.id().to_string()), + html_escape(c.author().name().unwrap_or_default()), + html_escape(c.summary().unwrap_or_default()), + )); + prev_date = Some(date); + } + page.body.push_str("</ul>"); + Ok(page.into()) + } + "raw" => { + if let Some(etag) = ctx + .parts + .headers + .get(header::IF_NONE_MATCH) + .and_then(|v| v.to_str().ok()) + { + if etag.trim_matches('"') == entr.id().to_string() { + return Ok(Builder::new() + .status(StatusCode::NOT_MODIFIED) + .body("".into()) + .unwrap()); + } + } + + let blob = ctx.repo.find_blob(entr.id()).unwrap(); + let mut resp = Response::new(blob.content().to_owned().into()); + + resp.headers_mut() + .insert(header::ETAG, format!("\"{}\"", entr.id()).parse().unwrap()); + resp.headers_mut() + .insert(header::CACHE_CONTROL, "no-cache".parse().unwrap()); + + if let Some(mime) = mime_guess::from_path(&ctx.path).first() { + if mime.type_() == "text" { + // workaround for Firefox, which downloads non-plain text subtypes + // instead of displaying them (https://bugzilla.mozilla.org/1319262) + resp.headers_mut() + .insert(header::CONTENT_TYPE, "text/plain".parse().unwrap()); + } else { + resp.headers_mut() + .insert(header::CONTENT_TYPE, mime.to_string().parse().unwrap()); + } + } + Ok(resp) + } + "move" => { + if !controller.may_move_path(&ctx) { + return Err(Error::Unauthorized( + "you are not authorized to move this file".into(), + ctx, + )); + } + return move_form( + filename, + &MoveForm { + dest: ctx.path.to_str().unwrap().to_owned(), + msg: None, + }, + None, + controller, + &ctx, + ); + } + "remove" => { + if !controller.may_move_path(&ctx) { + return Err(Error::Unauthorized( + "you are not authorized to remove this file".into(), + ctx, + )); + } + let page = Page { + title: format!("Remove {}", filename), + controller, + parts: &ctx.parts, + header: Some(action_links(¶ms.action, controller, &ctx)), + body: "<form method=post autocomplete=off>\ + <label>Message <input name=msg autofocus></label>\ + <button>Remove</button></form>" + .into(), + }; + + Ok(page.into()) + } + _ => Err(Error::BadRequest("unknown action".into())), + } +} + +fn view_tree<C: Controller>( + tree: Result<Tree, git2::Error>, + controller: &C, + ctx: &Context, +) -> Result<Response, Error> { + let mut page = Page { + title: ctx.path.to_string_lossy().to_string(), + controller, + parts: &ctx.parts, + body: String::new(), + header: None, + }; + + page.body.push_str("<ul>"); + page.body + .push_str("<li><a href='..' title='go to parent directory'>../</a></li>"); + + if let Ok(tree) = &tree { + let mut entries: Vec<_> = tree.iter().collect(); + entries.sort_by_key(|a| a.kind().unwrap().raw()); + + for entry in entries { + // if the name isn't valid utf8 we skip the entry + if let Some(name) = entry.name() { + if entry.kind() == Some(ObjectType::Tree) { + page.body.push_str(&format!( + "<li><a href='{0}/'>{0}/</a></li>", + html_escape(name) + )); + } else { + page.body.push_str(&format!( + "<li><a href='{0}'>{0}</a></li>", + html_escape(name) + )); + } + } + } + } + page.body.push_str("</ul>"); + + controller.before_return_tree_page(&mut page, tree.ok(), ctx); + + Ok(page.into()) +} diff --git a/src/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 |