aboutsummaryrefslogtreecommitdiff
path: root/src/controller.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/controller.rs')
-rw-r--r--src/controller.rs657
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!(" &amp; {}", iter.next().unwrap()));
+ }
+ out
+}
+
+fn username_from_parts(parts: &Parts) -> Option<&str> {
+ parts
+ .headers
+ .get(USERNAME_HEADER)
+ .and_then(|h| h.to_str().ok())
+}
+
+fn validate_formats(text: &str, extension: &str) -> Result<(), String> {
+ if extension == "toml" {
+ toml::from_str::<toml::Value>(text).map_err(|e| e.to_string())?;
+ }
+ Ok(())
+}
+
+impl Controller for MultiUserController {
+ fn before_route(&self, parts: &Parts) -> Result<(), Error> {
+ if !parts.headers.contains_key(USERNAME_HEADER) {
+ return Err(Error::BadRequest(format!(
+ "expected header {} because of multi-user mode \
+ (this shouldn't be happening because a reverse-proxy \
+ should be used to set this header)",
+ USERNAME_HEADER
+ )));
+ }
+ Ok(())
+ }
+
+ fn user_info_html(&self, parts: &Parts) -> Option<String> {
+ let username = username_from_parts(parts).unwrap();
+ Some(format!(
+ "<a href='/~{0}/' title='your home directory'>~{0}</a>",
+ html_escape(username)
+ ))
+ }
+
+ fn before_return_tree_page(&self, page: &mut Page, tree: Option<Tree>, context: &Context) {
+ let username = username_from_parts(&context.parts).unwrap();
+ if context.path.components().count() == 0 {
+ if context.branch.0 == username {
+ match tree {
+ None => page.body.push_str(EMPTY_HOME_HINT),
+ Some(tree) => {
+ if tree.iter().count() == 0 {
+ page.body.push_str(EMPTY_HOME_HINT);
+ } else if tree.get_path(Path::new(".shares.txt")).is_err() {
+ page.body.push_str("<p>Share files with other users by <a href='.shares.txt?action=edit'>creating a .shares.txt</a> config.</p>");
+ }
+ }
+ }
+ } else {
+ self.list_shares(context, username, &mut page.body);
+ }
+ }
+ }
+
+ fn before_return_error(&self, error: &Error) -> Option<Response> {
+ if let Error::Unauthorized(_, context) = error {
+ let username = username_from_parts(&context.parts).unwrap();
+ if context.path.components().count() == 0 {
+ let mut page = Page {
+ title: "".into(),
+ header: None,
+ body: String::new(),
+ controller: self,
+ parts: &context.parts,
+ };
+
+ self.list_shares(context, username, &mut page.body);
+ return Some(page.into());
+ }
+ }
+ None
+ }
+
+ fn signature(&self, _repo: &Repository, parts: &Parts) -> Result<Signature, Error> {
+ // TODO: return proper error message if header missing
+ let username = username_from_parts(parts).unwrap();
+ if let Some(identity) = self.identities.read().unwrap().0.get(username) {
+ Signature::now(identity.name.as_str(), &identity.email)
+ .map_err(|e| e.to_string())
+ .map_err(Error::Internal)
+ } else {
+ Signature::now(username, &format!("{}@localhost.invalid", username))
+ .map_err(|e| e.to_string())
+ .map_err(Error::Internal)
+ }
+ }
+
+ fn may_read_path(&self, ctx: &Context) -> bool {
+ let username = username_from_parts(&ctx.parts).unwrap();
+ if ctx.branch.0 == username {
+ return true;
+ }
+
+ let mut ok = false;
+ self.with_shares_cache(&ctx.repo, ctx.branch.clone(), |shares| {
+ if let Some(mode) = shares.find_mode(ctx.path.to_str().unwrap(), username) {
+ ok = true;
+ }
+ });
+ ok
+ }
+
+ fn may_write_path(&self, ctx: &Context) -> bool {
+ let username = username_from_parts(&ctx.parts).unwrap();
+ if ctx.branch.0 == username {
+ return true;
+ }
+
+ let mut ok = false;
+ self.with_shares_cache(&ctx.repo, ctx.branch.clone(), |shares| {
+ if let Some(AccessMode::ReadAndWrite) =
+ shares.find_mode(ctx.path.to_str().unwrap(), username)
+ {
+ ok = true;
+ }
+ });
+ ok
+ }
+
+ fn may_move_path(&self, ctx: &Context) -> bool {
+ ctx.branch.0 == username_from_parts(&ctx.parts).unwrap()
+ }
+
+ fn edit_hint_html(&self, ctx: &Context) -> Option<String> {
+ match (ctx.branch.0.as_str(), ctx.path.to_str().unwrap()) {
+ (_, ".shares.txt") => {
+ return Some(include_str!("help/shares.txt.html").into());
+ }
+ ("gitpad", "users.toml") => {
+ return Some(include_str!("help/users.toml.html").into());
+ }
+ _ => {}
+ }
+ None
+ }
+
+ fn before_write(&self, text: &str, ctx: &mut Context) -> Result<(), String> {
+ match (ctx.branch.0.as_str(), ctx.path.to_str().unwrap()) {
+ (_, ".shares.txt") => {
+ ctx.parts.extensions.insert(parse_shares_txt(text)?);
+ }
+ ("gitpad", "users.toml") => {
+ ctx.parts
+ .extensions
+ .insert(toml::from_str::<Identities>(text).map_err(|e| e.to_string())?);
+ }
+ _ => {
+ if let Some(ext) = ctx.path.extension().and_then(|e| e.to_str()) {
+ validate_formats(text, ext)?;
+ }
+ }
+ }
+ Ok(())
+ }
+
+ fn after_write(&self, ctx: &mut Context) {
+ match (ctx.branch.0.as_str(), ctx.path.to_str().unwrap()) {
+ (_, ".shares.txt") => {
+ self.shares_cache
+ .write()
+ .unwrap()
+ .insert(ctx.branch.clone(), ctx.parts.extensions.remove().unwrap());
+ }
+ ("gitpad", "users.toml") => {
+ *self.identities.write().unwrap() = ctx.parts.extensions.remove().unwrap();
+ }
+ _ => {}
+ }
+ }
+
+ fn access_info_html(&self, ctx: &Context) -> Option<String> {
+ let own_username = username_from_parts(&ctx.parts).unwrap();
+ if own_username != ctx.branch.0 {
+ return None;
+ }
+ let path = ctx.path.to_str().unwrap();
+ let mut result = None;
+ self.with_shares_cache(&ctx.repo, ctx.branch.clone(), |shares| {
+ let mut users = HashMap::new();
+ for rules in shares.rules_iter(path) {
+ for (username, rule) in &rules.0 {
+ if !users.contains_key(username) {
+ users.insert(username, rule);
+ }
+ }
+ }
+
+ if !users.is_empty() {
+ let (mut writers, mut readers): (Vec<_>, Vec<_>) = users
+ .iter()
+ .partition(|(_name, rule)| rule.mode == AccessMode::ReadAndWrite);
+ writers.sort_by(|a, b| a.0.cmp(b.0));
+ readers.sort_by(|a, b| a.0.cmp(b.0));
+
+ let mut out = String::new();
+ out.push_str("<div class=note>");
+ if !writers.is_empty() {
+ out.push_str(&format!(
+ "writable for {}",
+ join(
+ writers.len(),
+ &mut writers.iter().map(|r| link_share(own_username, *r))
+ )
+ ));
+ }
+ if !readers.is_empty() {
+ if !writers.is_empty() {
+ out.push_str(", ");
+ }
+ out.push_str(&format!(
+ "readable for {}",
+ join(
+ readers.len(),
+ &mut readers.iter().map(|r| link_share(own_username, *r))
+ )
+ ));
+ }
+ out.push_str("</div>");
+ result = Some(out)
+ }
+ });
+ result
+ }
+}