use std::collections::HashMap; use std::path::Path; use std::sync::RwLock; use git2::BranchType; 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 { /// 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 an absolute 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 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); impl SoloController { pub fn new(repo: &Repository) -> Self { Self(Branch( repo.find_reference("HEAD") .unwrap() .symbolic_target() .unwrap() .strip_prefix("refs/heads/") .unwrap() .to_owned(), )) } } 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 { format!("/{}", path) } 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() { validate_formats(text, ext)?; } Ok(()) } } #[derive(Deserialize)] #[serde(transparent)] struct Identities(HashMap); pub struct MultiUserController { identities: 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())), ), } } } const EMPTY_HOME_HINT: &str = "This is your home directory. Create files by editing the URL."; 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(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(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(); 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.branch.0 == username && context.path.components().count() == 0 { match tree { None => page.body.push_str(EMPTY_HOME_HINT), Some(tree) => { if tree.iter().count() == 0 { page.body.push_str(EMPTY_HOME_HINT); } } } } } 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(); return ctx.branch.0 == username; } fn may_write_path(&self, ctx: &Context, parts: &Parts) -> bool { let username = username_from_parts(parts).unwrap(); return ctx.branch.0 == username; } 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.as_str()) { ("gitpad", "users.toml") => { return Some(include_str!("static/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.as_str()) { ("gitpad", "users.toml") => { parts .extensions .insert(toml::from_str::(text).map_err(|e| e.to_string())?); } _ => { if let Some(ext) = ctx.path.extension() { validate_formats(text, ext)?; } } } Ok(()) } fn after_write(&self, ctx: &Context, parts: &mut Parts) { match (ctx.branch.0.as_str(), ctx.path.as_str()) { ("gitpad", "users.toml") => { *self.identities.write().unwrap() = parts.extensions.remove().unwrap(); } _ => {} } } }