use async_trait::async_trait; use git2::ObjectType; use sqlx::PgPool; use std::error::Error; use std::path::{Path, PathBuf}; use thiserror::Error; use crate::messages::Authenticated; use crate::model::instance::Instance; use crate::model::repository::{ Commit, RepositoryObjectType, RepositoryTreeEntry, RepositoryVisibility, }; use crate::{ messages::repository::{ CreateRepositoryRequest, CreateRepositoryResponse, RepositoryFileInspectRequest, RepositoryFileInspectionResponse, RepositoryInfoRequest, RepositoryIssueLabelsRequest, RepositoryIssueLabelsResponse, RepositoryIssuesCountRequest, RepositoryIssuesCountResponse, RepositoryIssuesRequest, RepositoryIssuesResponse, }, model::repository::RepositoryView, }; use super::{IssuesBackend, RepositoryBackend}; // TODO: Handle this //region database structures /// Repository in the database #[derive(Debug, sqlx::FromRow)] pub struct GitRepository { pub username: String, pub instance_url: String, pub name: String, pub description: Option, pub visibility: RepositoryVisibility, pub default_branch: String, } impl GitRepository { // Separate function because "Private" will be expanded later /// Checks if the user is allowed to view this repository pub fn can_user_view_repository(&self, instance_url: &str, username: Option<&str>) -> bool { !(matches!(self.visibility, RepositoryVisibility::Private) && self.instance_url != instance_url && self.username != username.map_or("", |username| username)) } // This is in it's own function because I assume I'll have to add logic to this later pub fn open_git2_repository( &self, repository_directory: &str, ) -> Result { match git2::Repository::open(format!( "{}/{}/{}/{}", repository_directory, self.instance_url, self.username, self.name )) { Ok(repository) => Ok(repository), Err(err) => { let err = GitBackendError::FailedOpeningFromDisk(err); error!("Couldn't open a repository, this is bad! {:?}", err); Err(err) } } } } //endregion #[derive(Error, Debug)] pub enum GitBackendError { #[error("Failed creating repository")] FailedCreatingRepository(git2::Error), #[error("Failed inserting into the database")] FailedInsertingIntoDatabase(sqlx::Error), #[error("Failed finding repository {instance_url:?}/{username:?}/{name:?}")] RepositoryNotFound { instance_url: String, username: String, name: String, }, #[error("Repository {instance_url:?}/{username:?}/{name:?} already exists")] RepositoryAlreadyExists { instance_url: String, username: String, name: String, }, #[error("Repository couldn't be deleted from the disk")] CouldNotDeleteFromDisk(std::io::Error), #[error("Failed deleting repository from database")] FailedDeletingFromDatabase(sqlx::Error), #[error("Failed opening repository on disk")] FailedOpeningFromDisk(git2::Error), #[error("Couldn't find ref with name `{0}`")] RefNotFound(String), #[error("Couldn't find path in repository `{0}`")] PathNotFound(String), #[error("Couldn't find commit for path `{0}`")] LastCommitNotFound(String), } pub struct GitBackend { pub pg_pool: PgPool, pub repository_folder: String, } impl GitBackend { pub fn new(pg_pool: &PgPool, repository_folder: &str) -> Self { Self { pg_pool: pg_pool.clone(), repository_folder: repository_folder.to_string(), } } pub async fn find_by_instance_username_name( &self, instance_url: &str, username: &str, repository_name: &str, ) -> Result { if let Ok(repository) = sqlx::query_as!(GitRepository, r#"SELECT username, instance_url, name, description, visibility as "visibility: _", default_branch FROM repositories WHERE instance_url = $1 AND username = $2 AND name = $3"#, instance_url, username, repository_name) .fetch_one(&self.pg_pool.clone()) .await { Ok(repository) } else { Err(GitBackendError::RepositoryNotFound { instance_url: instance_url.to_string(), username: username.to_string(), name: repository_name.to_string(), }) } } pub async fn delete_by_instance_username_name( &self, instance_url: &str, username: &str, repository_name: &str, ) -> Result { if let Err(err) = std::fs::remove_dir_all(PathBuf::from(format!( "{}/{}/{}/{}", self.repository_folder, instance_url, username, repository_name ))) { let err = GitBackendError::CouldNotDeleteFromDisk(err); error!( "Couldn't delete repository from disk, this is bad! {:?}", err ); return Err(err); } // Delete the repository from the database match sqlx::query!( "DELETE FROM repositories WHERE instance_url = $1 AND username = $2 AND name = $3", instance_url, username, repository_name ) .execute(&self.pg_pool.clone()) .await { Ok(deleted) => Ok(deleted.rows_affected()), Err(err) => Err(GitBackendError::FailedDeletingFromDatabase(err)), } } // TODO: Find where this fits // TODO: Cache this and general repository tree and invalidate select files on push // TODO: Find better and faster technique for this pub fn get_last_commit_of_file( path: &str, git: &git2::Repository, start_commit: &git2::Commit, ) -> anyhow::Result { let mut revwalk = git.revwalk()?; revwalk.set_sorting(git2::Sort::TIME)?; revwalk.push(start_commit.id())?; for oid in revwalk { let oid = oid?; let commit = git.find_commit(oid)?; // Merge commits have 2 or more parents // Commits with 0 parents are handled different because we can't diff against them if commit.parent_count() == 0 { return Ok(commit.into()); } else if commit.parent_count() == 1 { let tree = commit.tree()?; let last_tree = commit.parent(0)?.tree()?; // Get the diff between the current tree and the last one let diff = git.diff_tree_to_tree(Some(&last_tree), Some(&tree), None)?; for dd in diff.deltas() { // Get the path of the current file we're diffing against let current_path = dd.new_file().path().unwrap(); // Path or directory if current_path.eq(Path::new(&path)) || current_path.starts_with(path) { return Ok(commit.into()); } } } } Err(GitBackendError::LastCommitNotFound(path.to_string()))? } } #[async_trait] impl RepositoryBackend for GitBackend { async fn create_repository( &mut self, raw_request: &Authenticated, ) -> Result> { let request = raw_request.inner().await; let public_key = public_key(&Instance { url: String::from("giterated.dev"), }) .await .unwrap(); assert!(matches!(raw_request.validate(public_key).await, Ok(()))); info!("Request was valid!"); // Check if repository already exists in the database if let Ok(_repository) = self .find_by_instance_username_name( request.owner.instance.url.as_str(), request.owner.username.as_str(), request.name.as_str(), ) .await { let err = GitBackendError::RepositoryAlreadyExists { instance_url: request.owner.instance.url.clone(), username: request.owner.instance.url.clone(), name: request.name.clone(), }; error!("{:?}", err); return Ok(CreateRepositoryResponse::Failed); } // Insert the repository into the database let _ = match sqlx::query_as!(GitRepository, r#"INSERT INTO repositories VALUES ($1, $2, $3, $4, $5, $6) RETURNING username, instance_url, name, description, visibility as "visibility: _", default_branch"#, request.owner.username, request.owner.instance.url, request.name, request.description, request.visibility as _, "master") .fetch_one(&self.pg_pool.clone()) .await { Ok(repository) => repository, Err(err) => { let err = GitBackendError::FailedInsertingIntoDatabase(err); error!("Failed inserting into the database! {:?}", err); return Ok(CreateRepositoryResponse::Failed); } }; // Create bare (server side) repository on disk match git2::Repository::init_bare(PathBuf::from(format!( "{}/{}/{}/{}", self.repository_folder, request.owner.instance.url, request.owner.username, request.name ))) { Ok(_) => { debug!( "Created new repository with the name {}/{}/{}", request.owner.instance.url, request.owner.username, request.name ); Ok(CreateRepositoryResponse::Created) } Err(err) => { let err = GitBackendError::FailedCreatingRepository(err); error!("Failed creating repository on disk!? {:?}", err); // Delete repository from database if let Err(err) = self .delete_by_instance_username_name( request.owner.instance.url.as_str(), request.owner.username.as_str(), request.name.as_str(), ) .await { return Err(Box::new(err)); } // ??? Ok(CreateRepositoryResponse::Failed) //Err(Box::new(err)) } } } async fn repository_info( &mut self, request: &RepositoryInfoRequest, ) -> Result> { let repository = match self .find_by_instance_username_name( &request.owner.instance.url, &request.owner.username, &request.name, ) .await { Ok(repository) => repository, Err(err) => return Err(Box::new(err)), }; if !repository.can_user_view_repository( request.owner.instance.url.as_str(), Some(request.owner.username.as_str()), ) { return Err(Box::new(GitBackendError::RepositoryNotFound { instance_url: request.owner.instance.url.clone(), username: request.owner.username.clone(), name: request.name.clone(), })); } let git = match repository.open_git2_repository(&self.repository_folder) { Ok(git) => git, Err(err) => return Err(Box::new(err)), }; let rev_name = match &request.rev { None => { if let Ok(head) = git.head() { head.name().unwrap().to_string() } else { // Nothing in database, render empty tree. return Ok(RepositoryView { name: repository.name, description: repository.description, visibility: repository.visibility, default_branch: repository.default_branch, latest_commit: None, tree_rev: None, tree: vec![], }); } } Some(rev_name) => { // Find the reference, otherwise return GitBackendError match git .find_reference(format!("refs/heads/{}", rev_name).as_str()) .map_err(|_| GitBackendError::RefNotFound(rev_name.to_string())) { Ok(reference) => reference.name().unwrap().to_string(), Err(err) => return Err(Box::new(err)), } } }; // Get the git object as a commit let rev = match git .revparse_single(rev_name.as_str()) .map_err(|_| GitBackendError::RefNotFound(rev_name.to_string())) { Ok(rev) => rev, Err(err) => return Err(Box::new(err)), }; let commit = rev.as_commit().unwrap(); // this is stupid let mut current_path = rev_name.replace("refs/heads/", ""); // Get the commit tree let git_tree = if let Some(path) = &request.path { // Add it to our full path string current_path.push_str(format!("/{}", path).as_str()); // Get the specified path, return an error if it wasn't found. let entry = match commit .tree() .unwrap() .get_path(&PathBuf::from(path)) .map_err(|_| GitBackendError::PathNotFound(path.to_string())) { Ok(entry) => entry, Err(err) => return Err(Box::new(err)), }; // Turn the entry into a git tree entry.to_object(&git).unwrap().as_tree().unwrap().clone() } else { commit.tree().unwrap() }; // Iterate over the git tree and collect it into our own tree types let mut tree = git_tree .iter() .map(|entry| { let object_type = match entry.kind().unwrap() { ObjectType::Tree => RepositoryObjectType::Tree, ObjectType::Blob => RepositoryObjectType::Blob, _ => unreachable!(), }; let mut tree_entry = RepositoryTreeEntry::new(entry.name().unwrap(), object_type, entry.filemode()); if request.extra_metadata { // Get the file size if It's a blob let object = entry.to_object(&git).unwrap(); if let Some(blob) = object.as_blob() { tree_entry.size = Some(blob.size()); } // Could possibly be done better let path = if let Some(path) = current_path.split_once('/') { format!("{}/{}", path.1, entry.name().unwrap()) } else { entry.name().unwrap().to_string() }; // Get the last commit made to the entry if let Ok(last_commit) = GitBackend::get_last_commit_of_file(&path, &git, commit) { tree_entry.last_commit = Some(last_commit); } } tree_entry }) .collect::>(); // Sort the tree alphabetically and with tree first tree.sort_unstable_by_key(|entry| entry.name.to_lowercase()); tree.sort_unstable_by_key(|entry| { std::cmp::Reverse(format!("{:?}", entry.object_type).to_lowercase()) }); Ok(RepositoryView { name: repository.name, description: repository.description, visibility: repository.visibility, default_branch: repository.default_branch, latest_commit: None, tree_rev: Some(rev_name), tree, }) } fn repository_file_inspect( &mut self, _request: &RepositoryFileInspectRequest, ) -> Result> { todo!() } } impl IssuesBackend for GitBackend { fn issues_count( &mut self, _request: &RepositoryIssuesCountRequest, ) -> Result> { todo!() } fn issue_labels( &mut self, _request: &RepositoryIssueLabelsRequest, ) -> Result> { todo!() } fn issues( &mut self, _request: &RepositoryIssuesRequest, ) -> Result> { todo!() } } async fn public_key(instance: &Instance) -> Result> { let key = reqwest::get(format!("https://{}/.giterated/pubkey.pem", instance.url)) .await? .text() .await?; Ok(key) }