diff options
Diffstat (limited to 'src/controller.rs')
-rw-r--r-- | src/controller.rs | 657 |
1 files changed, 657 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 + } +} |