use std::collections::HashMap; use std::path::Path; use std::str::from_utf8; use std::sync::RwLock; use git2::Repository; use git2::Signature; use git2::Tree; use git2::{BranchType, ObjectType}; use hyper::http::request::Parts; use serde::Deserialize; use sputnik::html_escape; use crate::shares::{parse_shares_txt, AccessMode, AccessRule, Shares}; use crate::Branch; use crate::Context; use crate::Error; use crate::Page; use crate::Response; pub trait Controller { /// Parse the URL into a Branch and file path or intercept the request handling. fn parse_url_path<'a>( &'a self, path: &'a str, parts: &Parts, repo: &Repository, ) -> Result<(Branch, &'a str), Result>; /// Builds a URL path from a given Git branch and file path. fn build_url_path<'a>(&self, branch: &Branch, path: &'a str) -> String; /// Returns some HTML info to display for the current page. fn user_info_html(&self, parts: &Parts) -> Option { None } /// Returns an HTML string describing who has access to the context. fn access_info_html(&self, ctx: &Context, parts: &Parts) -> Option { None } /// Returns the author/committer signature used to create commits. fn signature(&self, repo: &Repository, parts: &Parts) -> Result; /// Returns whether or not a request is authorized to read a file or list a directory. fn may_read_path(&self, ctx: &Context, parts: &Parts) -> bool; /// Returns whether or not a request is authorized to write a file. fn may_write_path(&self, context: &Context, parts: &Parts) -> bool; /// Returns whether or not a request is authorized to (re)move a file. fn may_move_path(&self, context: &Context, parts: &Parts) -> bool; fn edit_hint_html(&self, context: &Context) -> Option { None } fn before_return_tree_page( &self, page: &mut Page, tree: Option, context: &Context, parts: &Parts, ); /// Executed before writing a file. Return an error to abort the writing process. fn before_write(&self, text: &str, ctx: &Context, parts: &mut Parts) -> Result<(), String> { Ok(()) } /// Executed after successfully writing a file. fn after_write(&self, context: &Context, parts: &mut Parts) {} } pub struct SoloController(pub Branch); const USERNAME_HEADER: &str = "Username"; impl Controller for SoloController { fn parse_url_path<'a>( &'a self, path: &'a str, parts: &Parts, repo: &Repository, ) -> Result<(Branch, &'a str), Result> { if parts.headers.contains_key(USERNAME_HEADER) { return Err(Err(Error::BadRequest(format!( "unexpected header {} (only \ allowed in multi-user mode), aborting to prevent accidental \ information leakage", USERNAME_HEADER )))); } Ok((self.0.clone(), path)) } fn build_url_path<'a>(&self, branch: &Branch, path: &'a str) -> String { path.to_owned() } fn signature(&self, repo: &Repository, parts: &Parts) -> Result { repo.signature().map_err(|e| Error::Internal(e.to_string())) } fn may_read_path(&self, ctx: &Context, parts: &Parts) -> bool { true } fn may_write_path(&self, _context: &Context, parts: &Parts) -> bool { true } fn may_move_path(&self, _context: &Context, parts: &Parts) -> bool { true } fn before_return_tree_page( &self, page: &mut Page, tree: Option, context: &Context, parts: &Parts, ) { if tree.map(|t| t.len()).unwrap_or_default() == 0 { page.body.push_str("

create files by editing the URL, e.g. /hello-world.md

"); } } fn before_write(&self, text: &str, ctx: &Context, parts: &mut Parts) -> 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); pub struct MultiUserController { identities: RwLock, shares_cache: RwLock>, } // using our own struct because git2::Signature isn't thread-safe #[derive(Deserialize)] struct Identity { name: String, email: String, } fn parse_identities(repo: &Repository) -> Option { 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()? } impl MultiUserController { pub fn new(repo: &Repository) -> Self { Self { identities: RwLock::new( parse_identities(repo).unwrap_or_else(|| Identities(HashMap::new())), ), shares_cache: RwLock::new(HashMap::new()), } } } impl MultiUserController { fn with_shares_cache( &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, repo: &Repository, branch: &Branch, username: &str, out: &mut String) { self.with_shares_cache(&repo, branch.clone(), |shares| { out.push_str("../"); 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.", branch.0)); } else { out.push_str(&format!( "{} has shared the following files with you:", branch.0 )); // TODO: display modes? out.push_str("
    "); for (path, mode) in exact_shares { out.push_str(&format!( "
  • {0}
  • ", html_escape(path) )); } for (path, mode) in prefix_shares { out.push_str(&format!( "
  • {0}/*
  • ", html_escape(path) )); } out.push_str("
"); } }); } } 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!( "{}", html_escape(own_username), rule.start, rule.end, html_escape(*username), ) } fn join(len: usize, iter: &mut dyn Iterator) -> 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::(text).map_err(|e| e.to_string())?; } Ok(()) } fn multi_user_startpage( controller: &MultiUserController, parts: &Parts, repo: &Repository, ) -> Result { // TODO: add domain name to title? let mut page = Page { title: "GitPad".into(), ..Default::default() }; 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."); } else { page.body.push_str("the following branches:"); page.body.push_str("
    "); for (branch, _) in repo.branches(Some(BranchType::Local))?.flatten() { page.body.push_str(&format!( "
  • ~{0}
  • ", html_escape(branch.name()?.unwrap()) )); } page.body.push_str("
"); } Ok(page.into()) } impl Controller for MultiUserController { fn parse_url_path<'a>( &'a self, path: &'a str, parts: &Parts, repo: &Repository, ) -> Result<(Branch, &'a str), Result> { if !parts.headers.contains_key(USERNAME_HEADER) { return Err(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 )))); } if path == "/" { return Err(multi_user_startpage(&self, parts, repo)); } let mut iter = path.splitn(3, '/'); iter.next(); let rev = iter.next().unwrap(); if !rev.starts_with('~') { return Err(Err(Error::NotFound( "branch name must be prefixed a tilde (~)".into(), ))); } let rev = &rev[1..]; if rev.trim().is_empty() { return Err(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(Err(Error::MissingTrailingSlash( parts.uri.path().to_string(), ))) } }; if unsanitized_path.is_empty() { let username = username_from_parts(&parts).unwrap(); if username != rev.0 { let mut page = Page::default(); self.list_shares(repo, &rev, username, &mut page.body); return Err(Ok(page.into())); } } Ok((rev, unsanitized_path)) } fn build_url_path<'a>(&self, branch: &Branch, path: &'a str) -> String { format!("/~{}/{}", branch.0, path) } fn user_info_html(&self, parts: &Parts) -> Option { let username = username_from_parts(parts).unwrap(); Some(format!( "~{0}", html_escape(username) )) } fn before_return_tree_page( &self, page: &mut Page, tree: Option, context: &Context, parts: &Parts, ) { let username = username_from_parts(&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("

Share files with other users by creating a .shares.txt config.

"); } } } } else { self.list_shares(&context.repo, &context.branch, username, &mut page.body); } } } fn signature(&self, _repo: &Repository, parts: &Parts) -> Result { // 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, parts: &Parts) -> bool { let username = username_from_parts(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, parts: &Parts) -> bool { let username = username_from_parts(&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, parts: &Parts) -> bool { ctx.branch.0 == username_from_parts(&parts).unwrap() } fn edit_hint_html(&self, ctx: &Context) -> Option { 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: &Context, parts: &mut Parts) -> Result<(), String> { match (ctx.branch.0.as_str(), ctx.path.to_str().unwrap()) { (_, ".shares.txt") => { parts.extensions.insert(parse_shares_txt(text)?); } ("gitpad", "users.toml") => { parts .extensions .insert(toml::from_str::(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: &Context, parts: &mut Parts) { match (ctx.branch.0.as_str(), ctx.path.to_str().unwrap()) { (_, ".shares.txt") => { self.shares_cache .write() .unwrap() .insert(ctx.branch.clone(), parts.extensions.remove().unwrap()); } ("gitpad", "users.toml") => { *self.identities.write().unwrap() = parts.extensions.remove().unwrap(); } _ => {} } } fn access_info_html(&self, ctx: &Context, parts: &Parts) -> Option { let own_username = username_from_parts(&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("
"); 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("
"); result = Some(out) } }); result } }