/* * Copyright (C) 2022 Aravinth Manivannan * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ use std::path::Path; use std::path::PathBuf; use git2::*; use mime_guess::MimeGuess; use num_enum::FromPrimitive; use serde::{Deserialize, Serialize}; use crate::errors::*; /// A FileMode represents the kind of tree entries used by git. It /// resembles regular file systems modes, although FileModes are /// considerably simpler (there are not so many), and there are some, /// like Submodule that has no file system equivalent. // Adapted from https://github.com/go-git/go-git/blob/master/plumbing/filemode/filemode.go(Apache-2.0 License) #[derive(Debug, PartialEq, Eq, Clone, FromPrimitive)] #[repr(isize)] pub enum GitFileMode { /// Empty is used as the GitFileMode of tree elements when comparing /// trees in the following situations: /// /// - the mode of tree elements before their creation. /// - the mode of tree elements after their deletion. /// - the mode of unmerged elements when checking the index. /// /// Empty has no file system equivalent. As Empty is the zero value /// of [GitFileMode] Empty = 0, /// Regular represent non-executable files. Regular = 0o100644, /// Dir represent a Directory. Dir = 0o40000, /// Deprecated represent non-executable files with the group writable bit set. This mode was /// supported by the first versions of git, but it has been deprecated nowadays. This /// library(github.com/go-git/go-git uses it, not realaravinth/gitpad at the moment) uses them /// internally, so you can read old packfiles, but will treat them as Regulars when interfacing /// with the outside world. This is the standard git behaviour. Deprecated = 0o100664, /// Executable represents executable files. Executable = 0o100755, /// Symlink represents symbolic links to files. Symlink = 0o120000, /// Submodule represents git submodules. This mode has no file system /// equivalent. Submodule = 0o160000, /// Unsupported file mode #[num_enum(default)] Unsupported = -1, } impl From<&'_ TreeEntry<'_>> for GitFileMode { fn from(t: &TreeEntry) -> Self { GitFileMode::from(t.filemode() as isize) } } impl From> for GitFileMode { fn from(t: TreeEntry) -> Self { GitFileMode::from(t.filemode() as isize) } } #[derive(Debug, Clone, Eq, PartialEq)] pub struct FileInfo { pub filename: String, pub content: ContentType, pub mime: MimeGuess, } #[derive(Serialize, Eq, PartialEq, Clone, Debug, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ContentType { Binary(Vec), Text(String), } impl ContentType { pub fn bytes(self) -> Vec { match self { Self::Text(text) => text.into(), Self::Binary(bin) => bin, } } pub fn from_blob(blob: &git2::Blob) -> Self { if blob.is_binary() { Self::Binary(blob.content().to_vec()) } else { Self::Text(String::from_utf8_lossy(blob.content()).to_string()) } } } /// Please note that this method expects path to not contain any spaces /// Use [escape_spaces] before calling this method /// /// For example, a read request for "foo bar.md" will fail even if that file is present /// in the repository. However, it will succeed if the output of [escape_spaces] is /// used in the request. pub fn read_file(repo_path: &PathBuf, path: &str) -> ServiceResult { let repo = git2::Repository::open(repo_path).unwrap(); let head = repo.head().unwrap(); let tree = head.peel_to_tree().unwrap(); read_file_inner(&repo, path, &tree) } pub fn read_preview_file( repo_path: &PathBuf, preview_name: &str, path: &str, ) -> ServiceResult { let repo = git2::Repository::open(repo_path).unwrap(); let branch = repo .find_branch(preview_name, git2::BranchType::Local) .unwrap(); // let tree = head.peel_to_tree().unwrap(); let branch = branch.into_reference(); let tree = branch.peel_to_tree().unwrap(); read_file_inner(&repo, path, &tree) } fn read_file_inner( repo: &git2::Repository, path: &str, tree: &git2::Tree, ) -> ServiceResult { fn read_file(id: Oid, repo: &git2::Repository) -> ContentType { let blob = repo.find_blob(id).unwrap(); ContentType::from_blob(&blob) } fn get_index_file(id: Oid, repo: &Repository) -> ContentType { let tree = repo.find_tree(id).unwrap(); const INDEX_FILES: [&str; 7] = [ "index.html", "index.md", "INDEX.md", "README.md", "README", "readme.txt", "readme", ]; let content = if let Some(index_file) = tree.iter().find(|x| { if let Some(name) = x.name() { INDEX_FILES.iter().any(|index_name| *index_name == name) } else { false } }) { read_file(index_file.id(), repo) } else { unimplemented!("Index file not found"); }; content } let inner = |repo: &git2::Repository, tree: &git2::Tree| -> ServiceResult { let mut path = path; if path == "/" { let content = get_index_file(tree.id(), repo); return Ok(FileInfo { filename: "/".into(), content, mime: mime_guess::from_path("index.html"), }); } if path.starts_with('/') { path = path.trim_start_matches('/'); } fn file_not_found(e: git2::Error) -> ServiceError { if e.code() == ErrorCode::NotFound { if e.class() == ErrorClass::Tree { return ServiceError::FileNotFound; } } return e.into(); } let entry = tree.get_path(Path::new(path)).map_err(file_not_found)?; let mode: GitFileMode = entry.clone().into(); if let Some(name) = entry.name() { let file = match mode { GitFileMode::Dir => get_index_file(entry.id(), repo), GitFileMode::Submodule => unimplemented!(), GitFileMode::Empty => unimplemented!(), GitFileMode::Deprecated => unimplemented!(), GitFileMode::Unsupported => unimplemented!(), GitFileMode::Symlink => unimplemented!(), GitFileMode::Executable => read_file(entry.id(), repo), GitFileMode::Regular => read_file(entry.id(), repo), }; Ok(FileInfo { filename: name.to_string(), mime: mime_guess::from_path(path), content: file, }) } else { unimplemented!(); } }; inner(repo, tree) } #[cfg(test)] pub mod tests { use super::*; const FILE_CONTENT: &str = "foobar"; fn write_file_util(path: &str) { // TODO change updated in DB let inner = |repo: &mut Repository| -> ServiceResult<()> { let mut tree_builder = match repo.head() { Err(_) => repo.treebuilder(None).unwrap(), Ok(h) => repo.treebuilder(Some(&h.peel_to_tree().unwrap())).unwrap(), }; let odb = repo.odb().unwrap(); let obj = odb .write(ObjectType::Blob, FILE_CONTENT.as_bytes()) .unwrap(); tree_builder.insert("README.txt", obj, 0o100644).unwrap(); let tree_hash = tree_builder.write().unwrap(); let author = Signature::now("librepages", "admin@librepages.org").unwrap(); let committer = Signature::now("librepages", "admin@librepages.org").unwrap(); let commit_tree = repo.find_tree(tree_hash).unwrap(); let msg = ""; if let Err(e) = repo.head() { if e.code() == ErrorCode::UnbornBranch && e.class() == ErrorClass::Reference { // fisrt commit ever; set parent commit(s) to empty array repo.commit(Some("HEAD"), &author, &committer, msg, &commit_tree, &[]) .unwrap(); } else { panic!("{:?}", e); } } else { let head_ref = repo.head().unwrap(); let head_commit = head_ref.peel_to_commit().unwrap(); repo.commit( Some("HEAD"), &author, &committer, msg, &commit_tree, &[&head_commit], ) .unwrap(); }; Ok(()) }; if Repository::open(path).is_err() { let _ = Repository::init(path); } let mut repo = Repository::open(path).unwrap(); let _ = inner(&mut repo); } #[test] fn test_git_write_read_works() { const PATH: &str = "/tmp/librepges/test_git_write_read_works"; write_file_util(PATH); let resp = read_file(&Path::new(PATH).into(), "README.txt").unwrap(); assert_eq!(resp.filename, "README.txt"); assert_eq!(resp.content.bytes(), FILE_CONTENT.as_bytes()); assert_eq!(resp.mime.first().unwrap(), "text/plain"); let resp = read_preview_file(&Path::new(PATH).into(), "master", "README.txt").unwrap(); assert_eq!(resp.filename, "README.txt"); assert_eq!(resp.content.bytes(), FILE_CONTENT.as_bytes()); assert_eq!(resp.mime.first().unwrap(), "text/plain"); assert_eq!( read_preview_file(&Path::new(PATH).into(), "master", "file-does-not-exist.txt"), Err(ServiceError::FileNotFound) ); } }