diff --git a/.gitignore b/.gitignore index ca19e2ca3..304d7c52e 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ monobean/resources/lib # Temporary test cache dir /tests/.cache_tmp + +## local python version file +.python-version \ No newline at end of file diff --git a/ceres/src/auth/mod.rs b/ceres/src/auth/mod.rs new file mode 100644 index 000000000..75a5401c3 --- /dev/null +++ b/ceres/src/auth/mod.rs @@ -0,0 +1,71 @@ +use anyhow::Result; +use common::errors::MegaError; +use jupiter::storage::user_storage::UserStorage; + +/// User authentication information extracted from Git push process +#[derive(Debug, Clone)] +pub struct PushUserInfo { + pub username: String, + pub primary_email: String, + pub all_emails: Vec, // User's email collection +} + +/// Extract user identity from AccessToken during Git push +#[allow(async_fn_in_trait)] +pub trait UserAuthExtractor { + /// Extract user information from AccessToken + async fn extract_user_from_token(&self, access_token: &str) -> Result; + + /// Verify if email belongs to the authenticated user + async fn verify_email_ownership(&self, username: &str, email: &str) -> Result; + + /// Extract user information from username (for cases where token is already validated) + async fn extract_user_from_username(&self, username: &str) -> Result; +} + +/// Default implementation for user authentication +pub struct DefaultUserAuthExtractor { + user_storage: UserStorage, +} + +impl DefaultUserAuthExtractor { + pub fn new(user_storage: UserStorage) -> Self { + Self { user_storage } + } +} + +impl UserAuthExtractor for DefaultUserAuthExtractor { + async fn extract_user_from_token(&self, access_token: &str) -> Result { + // Find username by token + let username = self.user_storage.find_user_by_token(access_token).await? + .ok_or_else(|| MegaError::with_message("Invalid or expired access token"))?; + + self.extract_user_from_username(&username).await + } + + async fn extract_user_from_username(&self, username: &str) -> Result { + // Get user information + let user = self.user_storage.find_user_by_name(username).await? + .ok_or_else(|| MegaError::with_message("User not found"))?; + + // Get user's email collection - for now, use the primary email + // TODO: Extend to support multiple emails per user + let all_emails = vec![user.email.clone()]; + + Ok(PushUserInfo { + username: user.name.clone(), + primary_email: user.email.clone(), + all_emails, + }) + } + + async fn verify_email_ownership(&self, username: &str, email: &str) -> Result { + // Get user information + let user = self.user_storage.find_user_by_name(username).await? + .ok_or_else(|| MegaError::with_message("User not found"))?; + + // Check if email matches primary email + // TODO: Extend to check against all user emails when multiple emails are supported + Ok(user.email.to_lowercase() == email.to_lowercase()) + } +} diff --git a/ceres/src/lib.rs b/ceres/src/lib.rs index cc7116ccb..83d2ab001 100644 --- a/ceres/src/lib.rs +++ b/ceres/src/lib.rs @@ -1,4 +1,5 @@ pub mod api_service; +pub mod auth; pub mod lfs; pub mod merge_checker; pub mod model; diff --git a/ceres/src/pack/monorepo.rs b/ceres/src/pack/monorepo.rs index 63922e769..fabbc00ee 100644 --- a/ceres/src/pack/monorepo.rs +++ b/ceres/src/pack/monorepo.rs @@ -158,7 +158,7 @@ impl RepoHandler for MonoRepo { } else { String::new() }; - storage.save_entry(&commit_id, entry_list).await + storage.save_entry(&commit_id, entry_list, self.username.clone()).await } async fn check_entry(&self, entry: &Entry) -> Result<(), GitError> { diff --git a/ceres/src/protocol/mod.rs b/ceres/src/protocol/mod.rs index be54be014..64da688b0 100644 --- a/ceres/src/protocol/mod.rs +++ b/ceres/src/protocol/mod.rs @@ -16,6 +16,7 @@ use import_refs::RefCommand; use jupiter::storage::Storage; use repo::Repo; +use crate::auth::{DefaultUserAuthExtractor, UserAuthExtractor, PushUserInfo}; use crate::pack::{import_repo::ImportRepo, monorepo::MonoRepo, RepoHandler}; pub mod import_refs; @@ -31,6 +32,7 @@ pub struct SmartProtocol { pub service_type: Option, pub storage: Storage, pub username: Option, + pub authenticated_user: Option, } #[derive(Debug, PartialEq, Clone, Copy, Default)] @@ -140,6 +142,7 @@ impl SmartProtocol { service_type: None, storage, username: None, + authenticated_user: None, } } @@ -153,6 +156,7 @@ impl SmartProtocol { service_type: None, storage, username: None, + authenticated_user: None, } } @@ -230,18 +234,57 @@ impl SmartProtocol { && username == auth_config.test_user_name && token == auth_config.test_user_token { + // For test user, create a basic PushUserInfo + match self.create_user_auth_extractor() { + Ok(user_auth) => { + if let Ok(user_info) = user_auth.extract_user_from_username(username).await { + self.authenticated_user = Some(user_info); + return true; + } else { + tracing::warn!("Failed to extract user info for test user: {}", username); + } + } + Err(e) => { + tracing::warn!("Failed to create user auth extractor for test user: {}", e); + } + } return true; } - return self + let token_valid = self .storage .user_storage() .check_token(username, token) .await - .unwrap(); + .unwrap_or(false); + + if token_valid { + // Extract user information for valid token + match self.create_user_auth_extractor() { + Ok(user_auth) => { + if let Ok(user_info) = user_auth.extract_user_from_username(username).await { + self.authenticated_user = Some(user_info); + return true; + } else { + tracing::warn!("Failed to extract user info for username: {}", username); + } + } + Err(e) => { + tracing::warn!("Failed to create user auth extractor: {}", e); + } + } + } + + return token_valid; } } false } + + /// Create a user authentication extractor + fn create_user_auth_extractor(&self) -> Result { + let user_storage = self.storage.user_storage(); + Ok(DefaultUserAuthExtractor::new(user_storage)) + } } #[cfg(test)] diff --git a/ceres/src/protocol/smart.rs b/ceres/src/protocol/smart.rs index c1c47777f..12eeaf86a 100644 --- a/ceres/src/protocol/smart.rs +++ b/ceres/src/protocol/smart.rs @@ -8,6 +8,7 @@ use tokio_stream::wrappers::ReceiverStream; use callisto::sea_orm_active_enums::RefTypeEnum; use common::errors::ProtocolError; +use crate::auth::UserAuthExtractor; use crate::pack::monorepo::MonoRepo; use crate::protocol::import_refs::RefCommand; use crate::protocol::ZERO_ID; @@ -251,6 +252,10 @@ impl SmartProtocol { } add_pkt_line_string(&mut report_status, command.get_status()); } + + // 4. Process commit bindings for successful ref updates + self.process_commit_bindings().await; + if repo_handler.is_monorepo() { let any = repo_handler.into_any(); if let Ok(monorepo) = any.downcast::() { @@ -332,6 +337,162 @@ impl SmartProtocol { read_until_white_space(pkt_line), ) } + + /// Process commit bindings for successfully pushed commits + async fn process_commit_bindings(&self) { + for command in &self.command_list { + // Only process successful branch updates (not tags or failed commands) + if command.ref_type == RefTypeEnum::Branch + && command.status == "ok" + && command.new_id != ZERO_ID + { + if let Err(e) = self.bind_commit_to_user(&command.new_id).await { + tracing::warn!("Failed to bind commit {} to user: {}", command.new_id, e); + // Don't fail the push on binding errors + } + } + } + } + + /// Bind a single commit to a user based on authenticated user and author email + async fn bind_commit_to_user( + &self, + commit_sha: &str, + ) -> Result<(), Box> { + let repo_handler = self.repo_handler().await?; + let commit_binding_storage = self.storage.commit_binding_storage(); + + // Extract author email from commit based on repo type + let author_email = if repo_handler.is_monorepo() { + // For monorepo, get from mono storage + let commit = self + .storage + .mono_storage() + .get_commit_by_hash(commit_sha) + .await?; + + if let Some(commit_model) = commit { + // Extract author email from commit (assuming author field contains "Name " format) + if let Some(author) = &commit_model.author { + extract_email_from_author(author) + } else { + return Ok(()); // Skip if no author info + } + } else { + return Ok(()); // Skip if commit not found + } + } else { + // For import repo, we need to find the repo_id first + let import_dir = self.storage.config().monorepo.import_dir.clone(); + if !self.path.starts_with(import_dir) { + return Ok(()); // Skip if not in import directory + } + + let path_str = self.path.to_str().unwrap(); + let repo = self + .storage + .git_db_storage() + .find_git_repo_exact_match(path_str) + .await?; + + if let Some(repo) = repo { + let commit = self + .storage + .git_db_storage() + .get_commit_by_hash(repo.id, commit_sha) + .await?; + + if let Some(commit_model) = commit { + // Extract author email from commit + if let Some(author) = &commit_model.author { + extract_email_from_author(author) + } else { + return Ok(()); // Skip if no author info + } + } else { + return Ok(()); // Skip if commit not found + } + } else { + return Ok(()); // Skip if repo not found + } + }; + + if author_email.is_empty() { + return Ok(()); // Skip if no valid email + } + + // Enhanced user matching logic based on authentication status + let (final_user_id, is_anonymous) = if let Some(authenticated_user) = &self.authenticated_user { + // User is authenticated - check if commit author email belongs to them + match self.create_user_auth_extractor() { + Ok(user_auth) => { + let email_belongs_to_user = user_auth + .verify_email_ownership(&authenticated_user.username, &author_email) + .await + .unwrap_or(false); + + if email_belongs_to_user { + // Author email belongs to authenticated user + (Some(authenticated_user.username.clone()), false) + } else { + // Author email doesn't belong to authenticated user - try general matching + let user_storage = self.storage.user_storage(); + let matched_user = user_storage.find_user_by_email(&author_email).await?; + + if let Some(user) = matched_user { + (Some(user.name.clone()), false) + } else { + // Mark as anonymous since we can't match the email + (None, true) + } + } + } + Err(e) => { + // Log the error from create_user_auth_extractor for debugging + tracing::warn!( + "Failed to create user auth extractor for commit binding: {}. \ + Falling back to email-based matching.", + e + ); + + // Fallback to email-based matching if auth extractor fails + let user_storage = self.storage.user_storage(); + let matched_user = user_storage.find_user_by_email(&author_email).await?; + + if let Some(user) = matched_user { + (Some(user.name.clone()), false) + } else { + (None, true) + } + } + } + } else { + // No authenticated user - try to match by email only + let user_storage = self.storage.user_storage(); + let matched_user = user_storage.find_user_by_email(&author_email).await?; + + if let Some(user) = matched_user { + (Some(user.name.clone()), false) + } else { + (None, true) + } + }; + + // Upsert the binding + commit_binding_storage + .upsert_binding(commit_sha, &author_email, final_user_id, is_anonymous) + .await?; + + tracing::info!( + "Bound commit {} (author: {}) to user: {:?} (authenticated: {})", + commit_sha, + author_email, + if is_anonymous { "anonymous" } else { "matched" }, + self.authenticated_user.is_some() + ); + + Ok(()) + } } fn read_until_white_space(bytes: &mut Bytes) -> String { @@ -400,6 +561,18 @@ pub fn read_pkt_line(bytes: &mut Bytes) -> (usize, Bytes) { (pkt_length, pkt_line) } +/// Extract email address from Git author string (format: "Name ") +fn extract_email_from_author(author: &str) -> String { + if let Some(start) = author.rfind('<') { + if let Some(end) = author.rfind('>') { + if start < end { + return author[start + 1..end].trim().to_string(); + } + } + } + String::new() +} + #[cfg(test)] pub mod test { use bytes::{Bytes, BytesMut}; diff --git a/docs/api.md b/docs/api.md index 4b1c6a242..77459f35b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -105,3 +105,17 @@ This part of the API, prefixed with /api/v1, is primarily for fetching Git raw o ```bash curl -X GET ${MEGA_URL}/api/v1/count-objs?repo_path= ``` + +6. Retrieve commit binding information for associating commits with users + + ```bash + curl -X GET ${MEGA_URL}/api/v1/commits//binding + ``` + +7. Update commit binding to associate a commit with a specific user or mark as anonymous + + ```bash + curl -X PUT ${MEGA_URL}/api/v1/commits//binding \ + -H "Content-Type: application/json" \ + -d '{"username": "", "is_anonymous": false}' + ``` diff --git a/docs/database.md b/docs/database.md index 19cfb42ce..e7a3bdcfb 100644 --- a/docs/database.md +++ b/docs/database.md @@ -43,6 +43,7 @@ Similar to the 'tree' in Git, Mega maintains relationships between files and fil | git_issue | Issues sync from third parties like GitHub. | | | | | | lfs_objects | Store objects related to LFS protocol. | | | | | | lfs_locks | Store locks for lfs files. | | | | | +| commit_auths | Store commit binding information associating commits with users. | ✓ | | | | ### ER Diagram @@ -81,6 +82,8 @@ erDiagram GIT-TAG }o--|| GIT-REPO : "belong to" GIT-TAG |o--o| GIT-COMMIT : points GIT-TAG ||--|| RAW-OBJECTS : points + COMMIT-AUTHS ||--o| GIT-COMMIT : binds + COMMIT-AUTHS ||--o| MEGA-COMMITS : binds ``` @@ -347,6 +350,19 @@ erDiagram | exist | BOOLEAN | | +#### commit_auths + +| Column | Type | Constraints | Description | +|------------------|-------------|-------------|--------------------------------------------------| +| id | VARCHAR | PRIMARY KEY | | +| commit_sha | VARCHAR | NOT NULL | Git commit SHA hash | +| author_email | VARCHAR | NOT NULL | Original author email from commit | +| matched_username | VARCHAR | NULL | Username that this commit is bound to | +| is_anonymous | BOOLEAN | NOT NULL | Whether the commit should be treated as anonymous | +| matched_at | TIMESTAMP | NULL | When the binding was created/updated | +| created_at | TIMESTAMP | NOT NULL | When the record was created | + + ## 3. Prerequisites diff --git a/gateway/Cargo.toml b/gateway/Cargo.toml index 38bcff04d..13d37d7a7 100644 --- a/gateway/Cargo.toml +++ b/gateway/Cargo.toml @@ -35,3 +35,4 @@ quinn = { workspace = true } utoipa = { workspace = true, features = ["axum_extras"] } utoipa-axum = { workspace = true } utoipa-swagger-ui = { workspace = true, features = ["axum"] } +anyhow = { workspace = true } diff --git a/jupiter/callisto/src/commit_auths.rs b/jupiter/callisto/src/commit_auths.rs new file mode 100644 index 000000000..723836abd --- /dev/null +++ b/jupiter/callisto/src/commit_auths.rs @@ -0,0 +1,26 @@ +//! `SeaORM` Entity for commit bindings + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "commit_auths")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: String, + #[sea_orm(column_type = "Text")] + pub commit_sha: String, + #[sea_orm(column_type = "Text")] + pub author_email: String, + // matched_username links to user.username (text) stored as string to be flexible + #[sea_orm(column_type = "Text", nullable)] + pub matched_username: Option, + pub is_anonymous: bool, + pub matched_at: Option, + pub created_at: DateTime, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/jupiter/callisto/src/mod.rs b/jupiter/callisto/src/mod.rs index d8065dc66..5da24b718 100644 --- a/jupiter/callisto/src/mod.rs +++ b/jupiter/callisto/src/mod.rs @@ -36,6 +36,7 @@ pub mod notes; pub mod path_check_configs; pub mod raw_blob; pub mod reactions; +pub mod commit_auths; pub mod relay_lfs_info; pub mod relay_node; pub mod relay_nostr_event; diff --git a/jupiter/callisto/src/prelude.rs b/jupiter/callisto/src/prelude.rs index 9a4ee3e2a..60b3cd483 100644 --- a/jupiter/callisto/src/prelude.rs +++ b/jupiter/callisto/src/prelude.rs @@ -32,6 +32,7 @@ pub use super::mq_storage::Entity as MqStorage; pub use super::notes::Entity as Notes; pub use super::path_check_configs::Entity as PathCheckConfigs; pub use super::raw_blob::Entity as RawBlob; +pub use super::commit_auths::Entity as CommitAuths; pub use super::reactions::Entity as Reactions; pub use super::relay_lfs_info::Entity as RelayLfsInfo; pub use super::relay_node::Entity as RelayNode; diff --git a/jupiter/src/migration/m20250904_120000_add_commit_auths.rs b/jupiter/src/migration/m20250904_120000_add_commit_auths.rs new file mode 100644 index 000000000..1c8467eb4 --- /dev/null +++ b/jupiter/src/migration/m20250904_120000_add_commit_auths.rs @@ -0,0 +1,86 @@ +use sea_orm::DatabaseBackend; +use sea_orm_migration::prelude::*; +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(CommitAuths::Table) + .if_not_exists() + .col( + ColumnDef::new(CommitAuths::Id) + .string() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(CommitAuths::CommitSha).string().not_null()) + .col(ColumnDef::new(CommitAuths::AuthorEmail).string().not_null()) + .col(ColumnDef::new(CommitAuths::MatchedUsername).string().null()) + .col( + ColumnDef::new(CommitAuths::IsAnonymous) + .boolean() + .not_null() + .default(false), + ) + .col(ColumnDef::new(CommitAuths::MatchedAt).timestamp().null()) + .col( + ColumnDef::new(CommitAuths::CreatedAt) + .timestamp() + .not_null(), + ) + .to_owned(), + ) + .await?; + + // add index on commit_sha for fast lookup + manager + .create_index( + Index::create() + .name("idx_commit_auths_commit_sha") + .table(CommitAuths::Table) + .col(CommitAuths::CommitSha) + .to_owned(), + ) + .await?; + + // set DB-side default for created_at depending on backend + let backend = manager.get_database_backend(); + match backend { + DatabaseBackend::Postgres => { + // set default to now() + manager + .get_connection() + .execute_unprepared( + r#"ALTER TABLE commit_auths ALTER COLUMN created_at SET DEFAULT now();"#, + ) + .await?; + } + DatabaseBackend::Sqlite | DatabaseBackend::MySql => {} + } + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(CommitAuths::Table).to_owned()) + .await?; + Ok(()) + } +} + +#[derive(DeriveIden)] +enum CommitAuths { + Table, + Id, + CommitSha, + AuthorEmail, + MatchedUsername, + IsAnonymous, + MatchedAt, + CreatedAt, +} diff --git a/jupiter/src/migration/mod.rs b/jupiter/src/migration/mod.rs index c2833d5a1..1d23de086 100644 --- a/jupiter/src/migration/mod.rs +++ b/jupiter/src/migration/mod.rs @@ -55,6 +55,7 @@ mod m20250828_092459_remove_gpg_table; mod m20250828_092729_create_standalone_table; mod m20250903_013904_create_task_table; mod m20250903_071928_add_issue_refs; +mod m20250904_120000_add_commit_auths; mod m20250904_074945_modify_tasks_and_builds; mod m20250905_163011_add_mr_reviewer; /// Creates a primary key column definition with big integer type. @@ -99,6 +100,7 @@ impl MigratorTrait for Migrator { Box::new(m20250828_092729_create_standalone_table::Migration), Box::new(m20250903_013904_create_task_table::Migration), Box::new(m20250903_071928_add_issue_refs::Migration), + Box::new(m20250904_120000_add_commit_auths::Migration), Box::new(m20250904_074945_modify_tasks_and_builds::Migration), Box::new(m20250905_163011_add_mr_reviewer::Migration), ] diff --git a/jupiter/src/storage/commit_binding_storage.rs b/jupiter/src/storage/commit_binding_storage.rs new file mode 100644 index 000000000..a881fbf52 --- /dev/null +++ b/jupiter/src/storage/commit_binding_storage.rs @@ -0,0 +1,77 @@ +use crate::storage::base_storage::{BaseStorage, StorageConnector}; +use callisto::commit_auths::Column::{CommitSha}; +use callisto::commit_auths::{ActiveModel, Entity}; +use common::errors::MegaError; +use sea_orm::{ + ActiveModelTrait, ActiveValue, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, +}; +use std::ops::Deref; +use uuid::Uuid; +#[derive(Clone)] +pub struct CommitBindingStorage { + pub base: BaseStorage, +} +impl Deref for CommitBindingStorage { + type Target = BaseStorage; + fn deref(&self) -> &Self::Target { + &self.base + } +} + +impl CommitBindingStorage { + pub fn new(base: BaseStorage) -> Self { + Self { base } + } + + /// Save or update a commit binding + pub async fn upsert_binding( + &self, + sha: &str, + author_email: &str, + matched_username: Option, + is_anonymous: bool, + ) -> Result<(), MegaError> { + let now = chrono::Utc::now().naive_utc(); + let model = ActiveModel { + id: ActiveValue::Set(Uuid::new_v4().to_string()), + commit_sha: ActiveValue::Set(sha.to_string()), + author_email: ActiveValue::Set(author_email.to_string()), + matched_username: ActiveValue::Set(matched_username.clone()), + is_anonymous: ActiveValue::Set(is_anonymous), + matched_at: ActiveValue::Set(Some(now)), + created_at: ActiveValue::Set(now), + }; + + // Try insert, if conflict update + let conn = self.get_connection(); + // Simple upsert: try find then insert/update + let existing = Entity::find() + .filter(CommitSha.eq(sha.to_string())) + .one(conn) + .await?; + + if let Some(e) = existing { + let mut am = e.into_active_model(); + am.matched_username = ActiveValue::Set(matched_username.clone()); + am.is_anonymous = ActiveValue::Set(is_anonymous); + am.matched_at = ActiveValue::Set(Some(now)); + am.update(conn).await?; + } else { + model.insert(conn).await?; + } + + Ok(()) + } + + pub async fn find_by_sha( + &self, + sha: &str, + ) -> Result, MegaError> { + let conn = self.get_connection(); + let res = Entity::find() + .filter(CommitSha.eq(sha.to_string())) + .one(conn) + .await?; + Ok(res) + } +} diff --git a/jupiter/src/storage/mod.rs b/jupiter/src/storage/mod.rs index bf3c940ae..42140bb92 100644 --- a/jupiter/src/storage/mod.rs +++ b/jupiter/src/storage/mod.rs @@ -1,4 +1,5 @@ pub mod base_storage; +pub mod commit_binding_storage; pub mod conversation_storage; pub mod git_db_storage; pub mod gpg_storage; @@ -25,10 +26,10 @@ use crate::service::mr_service::MRService; use crate::storage::conversation_storage::ConversationStorage; use crate::storage::init::database_connection; use crate::storage::{ - git_db_storage::GitDbStorage, gpg_storage::GpgStorage, issue_storage::IssueStorage, - lfs_db_storage::LfsDbStorage, mono_storage::MonoStorage, mr_storage::MrStorage, - raw_db_storage::RawDbStorage, relay_storage::RelayStorage, user_storage::UserStorage, - vault_storage::VaultStorage, + commit_binding_storage::CommitBindingStorage, git_db_storage::GitDbStorage, + gpg_storage::GpgStorage, issue_storage::IssueStorage, lfs_db_storage::LfsDbStorage, + mono_storage::MonoStorage, mr_storage::MrStorage, raw_db_storage::RawDbStorage, + relay_storage::RelayStorage, user_storage::UserStorage, vault_storage::VaultStorage, }; use crate::storage::base_storage::{BaseStorage, StorageConnector}; @@ -50,6 +51,7 @@ pub struct AppService { pub conversation_storage: ConversationStorage, pub lfs_file_storage: Arc, pub note_storage: NoteStorage, + pub commit_binding_storage: CommitBindingStorage, pub reviewer_storage: MrReviewerStorage, } @@ -70,6 +72,7 @@ impl AppService { issue_storage: IssueStorage { base: mock.clone() }, conversation_storage: ConversationStorage { base: mock.clone() }, note_storage: NoteStorage { base: mock.clone() }, + commit_binding_storage: CommitBindingStorage { base: mock.clone() }, reviewer_storage: MrReviewerStorage { base: mock.clone() }, }) } @@ -101,6 +104,7 @@ impl Storage { let conversation_storage = ConversationStorage { base: base.clone() }; let lfs_file_storage = lfs_storage::init(config.lfs.clone(), connection.clone()).await; let note_storage = NoteStorage { base: base.clone() }; + let commit_binding_storage = CommitBindingStorage { base: base.clone() }; let reviewer_storage = MrReviewerStorage { base: base.clone() }; let app_service = AppService { @@ -117,6 +121,7 @@ impl Storage { conversation_storage, lfs_file_storage, note_storage, + commit_binding_storage, reviewer_storage, }; Storage { @@ -183,6 +188,10 @@ impl Storage { self.app_service.note_storage.clone() } + + pub fn commit_binding_storage(&self) -> CommitBindingStorage { + self.app_service.commit_binding_storage.clone() + } pub fn reviewer_storage(&self) -> MrReviewerStorage { self.app_service.reviewer_storage.clone() } diff --git a/jupiter/src/storage/mono_storage.rs b/jupiter/src/storage/mono_storage.rs index dbae94072..696dbd994 100644 --- a/jupiter/src/storage/mono_storage.rs +++ b/jupiter/src/storage/mono_storage.rs @@ -11,10 +11,12 @@ use callisto::{mega_blob, mega_commit, mega_refs, mega_tag, mega_tree, raw_blob} use common::config::MonoConfig; use common::errors::MegaError; use common::utils::{generate_id, MEGA_BRANCH_NAME}; -use mercury::internal::object::MegaObjectModel; +use mercury::internal::object::{MegaObjectModel, ObjectTrait}; use mercury::internal::{object::commit::Commit, pack::entry::Entry}; use crate::storage::base_storage::{BaseStorage, StorageConnector}; +use crate::storage::commit_binding_storage::CommitBindingStorage; +use crate::storage::user_storage::UserStorage; use crate::utils::converter::MegaModelConverter; #[derive(Clone)] @@ -39,6 +41,18 @@ struct GitObjects { } impl MonoStorage { + pub fn user_storage(&self) -> UserStorage { + UserStorage { + base: self.base.clone(), + } + } + + pub fn commit_binding_storage(&self) -> CommitBindingStorage { + CommitBindingStorage { + base: self.base.clone(), + } + } + pub async fn save_ref( &self, path: &str, @@ -137,6 +151,7 @@ impl MonoStorage { &self, commit_id: &str, entry_list: Vec, + authenticated_username: Option, ) -> Result<(), MegaError> { let git_objects = Arc::new(Mutex::new(GitObjects { commits: Vec::new(), @@ -146,15 +161,24 @@ impl MonoStorage { tags: Vec::new(), })); + // Collect commits for binding processing + let commits_to_process = Arc::new(Mutex::new(Vec::<(String, String)>::new())); + stream::iter(entry_list) .for_each_concurrent(None, |entry| { let git_objects = git_objects.clone(); + let commits_to_process = commits_to_process.clone(); async move { let raw_obj = entry.process_entry(); let model = raw_obj.convert_to_mega_model(); let mut git_objects = git_objects.lock().unwrap(); match model { MegaObjectModel::Commit(commit) => { + // Store for binding processing + if let Ok(commit_obj) = mercury::internal::object::commit::Commit::from_bytes(&entry.data, entry.hash) { + let mut commits = commits_to_process.lock().unwrap(); + commits.push((commit_obj.id.to_string(), commit_obj.author.email.clone())); + } git_objects.commits.push(commit.into_active_model()) } MegaObjectModel::Tree(mut tree) => { @@ -183,6 +207,61 @@ impl MonoStorage { self.batch_save_model(git_objects.raw_blobs).await.unwrap(); self.batch_save_model(git_objects.tags).await.unwrap(); + // Process commit author bindings after saving objects + let commits_to_process = Arc::try_unwrap(commits_to_process) + .expect("Failed to unwrap Arc") + .into_inner() + .unwrap(); + + if !commits_to_process.is_empty() { + self.process_commit_bindings(&commits_to_process, authenticated_username.as_deref()).await?; + } + + Ok(()) + } + + /// Process commit author bindings + async fn process_commit_bindings(&self, commits: &[(String, String)], authenticated_username: Option<&str>) -> Result<(), MegaError> { + let user_storage = self.user_storage(); + let commit_binding_storage = self.commit_binding_storage(); + + for (commit_sha, author_email) in commits { + // Try to find user by authenticated username first + let matched_username = if let Some(username) = authenticated_username { + // If authenticated username is available, use it to find user + match user_storage.find_user_by_name(username).await { + Ok(Some(_user)) => { + tracing::info!("Found user for username: {}", username); + Some(username.to_string()) + } + Ok(None) => { + tracing::warn!("Authenticated username {} not found in user table", username); + None + } + Err(e) => { + tracing::error!("Error finding user by username {}: {}", username, e); + None + } + } + } else { + // No authenticated username, commit will be anonymous + tracing::info!("No authenticated username available for commit {}", commit_sha); + None + }; + + let is_anonymous = matched_username.is_none(); + + // Save or update binding + if let Err(e) = commit_binding_storage + .upsert_binding(commit_sha, author_email, matched_username, is_anonymous) + .await + { + tracing::error!("Failed to save commit binding for {}: {}", commit_sha, e); + // Continue processing other commits even if one fails + } else { + tracing::info!("Processed binding for commit {} with email {} (anonymous: {})", commit_sha, author_email, is_anonymous); + } + } Ok(()) } diff --git a/jupiter/src/storage/user_storage.rs b/jupiter/src/storage/user_storage.rs index 6e6394499..614981c91 100644 --- a/jupiter/src/storage/user_storage.rs +++ b/jupiter/src/storage/user_storage.rs @@ -143,6 +143,17 @@ impl UserStorage { None => Ok(false), } } + + pub async fn find_user_by_token(&self, token: &str) -> Result, MegaError> { + let res = access_token::Entity::find() + .filter(access_token::Column::Token.eq(token)) + .one(self.get_connection()) + .await?; + match res { + Some(token_model) => Ok(Some(token_model.username)), + None => Ok(None), + } + } } #[cfg(test)] diff --git a/jupiter/src/tests.rs b/jupiter/src/tests.rs index 9db72abab..ab822f6c7 100644 --- a/jupiter/src/tests.rs +++ b/jupiter/src/tests.rs @@ -13,10 +13,11 @@ use crate::storage::base_storage::{BaseStorage, StorageConnector}; use crate::storage::gpg_storage::GpgStorage; use crate::storage::note_storage::NoteStorage; use crate::storage::{ - conversation_storage::ConversationStorage, git_db_storage::GitDbStorage, - issue_storage::IssueStorage, lfs_db_storage::LfsDbStorage, mono_storage::MonoStorage, - mr_reviewer_storage::MrReviewerStorage, mr_storage::MrStorage, raw_db_storage::RawDbStorage, - relay_storage::RelayStorage, user_storage::UserStorage, vault_storage::VaultStorage, + commit_binding_storage::CommitBindingStorage, conversation_storage::ConversationStorage, + git_db_storage::GitDbStorage, issue_storage::IssueStorage, lfs_db_storage::LfsDbStorage, + mr_reviewer_storage::MrReviewerStorage, mono_storage::MonoStorage, mr_storage::MrStorage, + raw_db_storage::RawDbStorage,relay_storage::RelayStorage, user_storage::UserStorage, + vault_storage::VaultStorage, }; use crate::storage::{AppService, Storage}; @@ -59,7 +60,9 @@ pub async fn test_storage(temp_dir: impl AsRef) -> Storage { conversation_storage: ConversationStorage { base: base.clone() }, lfs_file_storage: Arc::new(LocalStorage::mock()), // fix it when you really use it. note_storage: NoteStorage { base: base.clone() }, + commit_binding_storage: CommitBindingStorage { base: base.clone() }, reviewer_storage: MrReviewerStorage { base: base.clone() }, + }; apply_migrations(&connection, true).await.unwrap(); diff --git a/mono/src/api/api_router.rs b/mono/src/api/api_router.rs index e6b30ce9f..1733df99a 100644 --- a/mono/src/api/api_router.rs +++ b/mono/src/api/api_router.rs @@ -20,8 +20,8 @@ use common::model::CommonResult; use utoipa_axum::{router::OpenApiRouter, routes}; use crate::api::{ - conversation::conv_router, gpg::gpg_router, issue::issue_router, label::label_router, - mr::mr_router, notes::note_router, user::user_router, MonoApiServiceState, + commit::commit_router, conversation::conv_router, gpg::gpg_router, issue::issue_router, + label::label_router, mr::mr_router, notes::note_router, user::user_router, MonoApiServiceState, }; use crate::{api::error::ApiError, server::http_server::GIT_TAG}; @@ -45,6 +45,7 @@ pub fn routers() -> OpenApiRouter { .merge(label_router::routers()) .merge(conv_router::routers()) .merge(note_router::routers()) + .merge(commit_router::routers()) } /// Get blob file as string diff --git a/mono/src/api/commit/commit_router.rs b/mono/src/api/commit/commit_router.rs new file mode 100644 index 000000000..ac2bbf570 --- /dev/null +++ b/mono/src/api/commit/commit_router.rs @@ -0,0 +1,175 @@ +use super::model::{CommitBinding, CommitBindingResponse, UserInfo}; +use crate::api::{error::ApiError, MonoApiServiceState}; +use crate::server::http_server::GIT_TAG; +use axum::{ + extract::{Path, State}, + Json, +}; +use common::model::CommonResult; +use utoipa_axum::{router::OpenApiRouter, routes}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] +pub struct UpdateCommitBindingRequest { + pub username: Option, + pub is_anonymous: bool, +} + +pub fn routers() -> OpenApiRouter { + OpenApiRouter::new() + .routes(routes!(get_commit_binding)) + .routes(routes!(update_commit_binding)) +} + +/// Get commit binding information by commit SHA +#[utoipa::path( + get, + path = "/commits/{sha}/binding", + params( + ("sha" = String, Path, description = "Git commit SHA hash") + ), + responses( + (status = 200, description = "Get commit binding information successfully", + body = CommonResult, content_type = "application/json"), + (status = 404, description = "Commit binding not found") + ), + tag = GIT_TAG +)] +#[axum::debug_handler] +async fn get_commit_binding( + State(state): State, + Path(sha): Path, +) -> Result>, ApiError> { + let commit_binding_storage = state.storage.commit_binding_storage(); + let user_storage = state.storage.user_storage(); + + match commit_binding_storage.find_by_sha(&sha).await { + Ok(Some(binding_model)) => { + // Try to get user information if not anonymous + let user_info = if !binding_model.is_anonymous && binding_model.matched_username.is_some() { + let username = binding_model.matched_username.as_ref().unwrap(); + if let Ok(Some(user)) = user_storage.find_user_by_name(username).await { + Some(UserInfo { + id: user.id.to_string(), + username: user.name.clone(), + display_name: Some(user.name.clone()), // Use name as display_name (fallback if no separate display name is available) + avatar_url: Some(user.avatar_url.clone()), + email: user.email.clone(), + }) + } else { + None + } + } else { + None + }; + + let binding = CommitBinding { + id: binding_model.id, + commit_sha: binding_model.commit_sha, + author_email: binding_model.author_email.clone(), + matched_username: binding_model.matched_username, + is_anonymous: binding_model.is_anonymous, + matched_at: binding_model.matched_at.map(|dt| dt.and_utc().to_rfc3339()), + created_at: binding_model.created_at.and_utc().to_rfc3339(), + user: user_info.clone(), + }; + + // Prepare display information + let (display_name, avatar_url, is_verified_user) = if binding_model.is_anonymous { + ("Anonymous".to_string(), None, false) + } else if let Some(ref user) = user_info { + ( + user.display_name.clone().unwrap_or(user.username.clone()), + user.avatar_url.clone(), + true + ) + } else { + // Fallback for cases where user is matched but user info is not available + (binding_model.author_email.split('@').next().unwrap_or(&binding_model.author_email).to_string(), None, false) + }; + + Ok(Json(CommonResult::success(Some(CommitBindingResponse { + binding: Some(binding), + display_name, + avatar_url, + is_verified_user, + })))) + } + Ok(None) => Ok(Json(CommonResult::success(Some(CommitBindingResponse { + binding: None, + display_name: "Anonymous".to_string(), + avatar_url: None, + is_verified_user: false, + })))), + Err(e) => { + tracing::error!("Failed to query commit binding for {}: {}", sha, e); + Err(ApiError::from(anyhow::anyhow!( + "Database query failed: {}", + e + ))) + } + } +} + +/// Update commit binding information +#[utoipa::path( + put, + path = "/commits/{sha}/binding", + params( + ("sha" = String, Path, description = "Git commit SHA hash") + ), + request_body = UpdateCommitBindingRequest, + responses( + (status = 200, description = "Update commit binding information successfully", + body = CommonResult, content_type = "application/json"), + (status = 404, description = "Commit not found"), + (status = 400, description = "Invalid request") + ), + tag = GIT_TAG +)] +#[axum::debug_handler] +async fn update_commit_binding( + State(state): State, + Path(sha): Path, + Json(request): Json, +) -> Result>, ApiError> { + let commit_binding_storage = state.storage.commit_binding_storage(); + let user_storage = state.storage.user_storage(); + + // First check if commit binding exists + let existing_binding = commit_binding_storage.find_by_sha(&sha).await + .map_err(|e| ApiError::from(anyhow::anyhow!("Database query failed: {}", e)))?; + + let author_email = if let Some(ref binding) = existing_binding { + binding.author_email.clone() + } else { + // If no binding exists, we need the author email - this could be passed in request or derived from git + return Err(ApiError::from(anyhow::anyhow!("No existing binding found for commit {}", sha))); + }; + + // Validate user if not anonymous + if !request.is_anonymous { + if let Some(ref username) = request.username { + let user_exists = user_storage.find_user_by_name(username).await + .map_err(|e| ApiError::from(anyhow::anyhow!("User validation failed: {}", e)))?; + + if user_exists.is_none() { + return Err(ApiError::from(anyhow::anyhow!("User not found: {}", username))); + } + } else { + return Err(ApiError::from(anyhow::anyhow!("Username required when not anonymous"))); + } + } + + // Update the binding + commit_binding_storage.upsert_binding( + &sha, + &author_email, + request.username.clone(), + request.is_anonymous, + ).await + .map_err(|e| ApiError::from(anyhow::anyhow!("Failed to update binding: {}", e)))?; + + // Return updated binding information + get_commit_binding(State(state), Path(sha)).await +} diff --git a/mono/src/api/commit/mod.rs b/mono/src/api/commit/mod.rs new file mode 100644 index 000000000..4cc8c62bf --- /dev/null +++ b/mono/src/api/commit/mod.rs @@ -0,0 +1,2 @@ +pub mod commit_router; +pub mod model; diff --git a/mono/src/api/commit/model.rs b/mono/src/api/commit/model.rs new file mode 100644 index 000000000..3bcaa5e75 --- /dev/null +++ b/mono/src/api/commit/model.rs @@ -0,0 +1,31 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UserInfo { + pub id: String, + pub username: String, + pub display_name: Option, + pub avatar_url: Option, + pub email: String, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct CommitBinding { + pub id: String, + pub commit_sha: String, + pub author_email: String, + pub matched_username: Option, + pub is_anonymous: bool, + pub matched_at: Option, + pub created_at: String, + pub user: Option, // Enhanced: Include user object information +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct CommitBindingResponse { + pub binding: Option, + pub display_name: String, // Enhanced: Ready-to-use display name + pub avatar_url: Option, // Enhanced: Ready-to-use avatar URL + pub is_verified_user: bool, // Enhanced: Whether the user is verified in the system +} diff --git a/mono/src/api/mod.rs b/mono/src/api/mod.rs index 8cbfb27c6..95f4fd0d8 100644 --- a/mono/src/api/mod.rs +++ b/mono/src/api/mod.rs @@ -25,6 +25,7 @@ use jupiter::storage::{gpg_storage::GpgStorage, note_storage::NoteStorage}; pub mod api_common; pub mod api_router; +pub mod commit; pub mod conversation; pub mod error; mod gpg;