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 { None } /// Returns an HTML string describing who has access to the context. fn access_info_html(&self, ctx: &Context) -> 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, 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 { None } fn before_return_tree_page(&self, page: &mut Page, tree: Option, 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 { 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 { 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); pub struct MultiUserController { identities: RwLock, shares_cache: RwLock>, } /// Maps paths to access rules. #[derive(Default, Debug)] pub struct Shares { exact_rules: HashMap, prefix_rules: HashMap, } /// Maps usernames to access modes and .shares.txt source line. #[derive(Default, Debug)] struct AccessRuleset(HashMap); #[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 = 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 { 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 { let mut exact_rules: HashMap = HashMap::new(); let mut prefix_rules: HashMap = 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( &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("../"); 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("
    "); 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(()) } 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 { 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) { 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("

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

"); } } } } else { self.list_shares(context, username, &mut page.body); } } } fn before_return_error(&self, error: &Error) -> Option { 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 { // 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 { 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::(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 { 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("
"); 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 } }