use argon2::{ Argon2, PasswordHash, PasswordVerifier, password_hash::{PasswordHasher, SaltString, rand_core::OsRng}, }; use rand_core::RngCore; use sha2::{Digest, Sha256}; use crate::{ app::AppState, db::{NewRepository, NewUser}, error::{AppError, AppResult}, git, models::{ AccessMode, AccessTokenResponse, Branch, CollaboratorResponse, CompareCommit, CompareFile, CompareRequest, CompareResponse, CreateAccessTokenRequest, CreateAccessTokenResponse, CreatePullRequestRequest, CreateRepositoryRequest, CreateUserRequest, ForkRepositoryRequest, LoginRequest, LoginResponse, MergePullRequestRequest, PullRequest, PullRequestDetailResponse, PullRequestResponse, PullRequestStatus, RepositoryPermission, RepositoryWithOwner, UpsertCollaboratorRequest, User, }, repox, }; const TOKEN_TTL_DAYS: i64 = 365; const MAX_PER_PAGE: i64 = 100; pub fn create_user(state: &AppState, req: CreateUserRequest) -> AppResult { validate_user_name(&req.username)?; validate_email(&req.email)?; if req.password.len() < 8 { return Err(AppError::Validation( "password must be at least 8 characters".to_string(), )); } let salt = SaltString::generate(&mut OsRng); let password_hash = Argon2::default() .hash_password(req.password.as_bytes(), &salt) .map_err(|err| AppError::Validation(format!("password hashing failed: {err}")))? .to_string(); state.db.create_user(NewUser { username: &req.username, full_name: &req.full_name, email: &req.email, password_hash: &password_hash, is_active: req.is_active, is_admin: req.is_admin, }) } pub fn get_user(state: &AppState, username: &str) -> AppResult { state .db .get_user_by_username(username)? .ok_or_else(|| AppError::NotFound(format!("user not found: {username}"))) } pub fn should_allow_bootstrap_admin(state: &AppState) -> AppResult { Ok(state.db.user_count()? == 0) } pub fn login(state: &AppState, req: LoginRequest) -> AppResult { let user = authenticate_password(state, &req.login, &req.password)?; let token = issue_login_access_token(state, user.id)?; Ok(LoginResponse { token: token.token, user, }) } fn issue_login_access_token(state: &AppState, user_id: i64) -> AppResult { let token = random_token(); let token_hash = hash_token(&token); let record = state.db.replace_access_token(user_id, "login", &token_hash)?; Ok(CreateAccessTokenResponse { id: record.id, name: record.name, token, created_unix: record.created_unix, updated_unix: record.updated_unix, }) } pub fn issue_access_token( state: &AppState, user_id: i64, req: CreateAccessTokenRequest, ) -> AppResult { if req.name.trim().is_empty() { return Err(AppError::Validation( "token name cannot be empty".to_string(), )); } let token = random_token(); let token_hash = hash_token(&token); let record = state .db .create_access_token(user_id, req.name.trim(), &token_hash)?; Ok(CreateAccessTokenResponse { id: record.id, name: record.name, token, created_unix: record.created_unix, updated_unix: record.updated_unix, }) } pub fn list_access_tokens( state: &AppState, user_id: i64, page: i64, per_page: i64, ) -> AppResult> { let (limit, offset) = pagination(page, per_page); let tokens = state.db.list_access_tokens_by_user(user_id, limit, offset)?; Ok(tokens .into_iter() .map(|token| AccessTokenResponse { id: token.id, name: token.name, created_unix: token.created_unix, updated_unix: token.updated_unix, }) .collect()) } pub fn delete_access_token(state: &AppState, user_id: i64, token_id: i64) -> AppResult<()> { if state.db.delete_access_token_by_id(user_id, token_id)? { return Ok(()); } Err(AppError::NotFound(format!( "access token not found: {token_id}" ))) } pub fn authenticate_token(state: &AppState, bearer_token: &str) -> AppResult { let token_hash = hash_token(bearer_token); let token = state .db .get_access_token_by_hash(&token_hash)? .ok_or_else(|| AppError::Unauthorized("invalid access token".to_string()))?; if is_token_expired(&token) { return Err(AppError::Unauthorized("access token expired".to_string())); } if let Err(err) = state.db.touch_access_token(token.id) { eprintln!("warning: touch_access_token({}) failed: {err}", token.id); } let user = state .db .get_user_by_id(token.user_id)? .ok_or_else(|| AppError::Unauthorized("token owner not found".to_string()))?; if !user.is_active { return Err(AppError::Unauthorized("inactive user".to_string())); } Ok(user) } pub fn authenticate_http_basic(state: &AppState, login: &str, secret: &str) -> AppResult { match login_with_password(state, login, secret) { Ok(user) => Ok(user), Err(AppError::Unauthorized(_)) => { authenticate_token(state, secret).or_else(|_| authenticate_token(state, login)) } Err(err) => Err(err), } } pub fn create_repository( state: &AppState, owner: &User, req: CreateRepositoryRequest, ) -> AppResult { validate_repo_name(&req.name)?; let repo_path = repox::repository_path(&state.config.repository.root, &owner.name, &req.name); if repo_path.exists() { return Err(AppError::Conflict(format!( "repository directory already exists: {}", repo_path.display() ))); } let repo = state.db.create_repository(NewRepository { owner_id: owner.id, owner_name: &owner.name, name: &req.name, description: &req.description, default_branch: &state.config.repository.default_branch, is_private: req.is_private, is_bare: !req.auto_init, is_fork: false, fork_id: 0, })?; if let Err(err) = git::init_bare_repo_with_binary( &state.config.repository.git_binary, &repo_path, &state.config.repository.default_branch, ) { let _ = std::fs::remove_dir_all(&repo_path); let _ = state.db.delete_repository_by_id(repo.id); return Err(err); } if req.auto_init { let readme = format!("# {}\n", req.name); if let Err(err) = git::create_initial_commit_with_binary( &state.config.repository.git_binary, &repo_path, &state.config.repository.default_branch, &readme, &owner.name, &owner.email, ) { let _ = std::fs::remove_dir_all(&repo_path); let _ = state.db.delete_repository_by_id(repo.id); return Err(err); } } Ok(RepositoryWithOwner { repo, owner: owner.clone(), }) } pub fn fork_repository( state: &AppState, acting_user: &User, base_owner_name: &str, base_repo_name: &str, req: ForkRepositoryRequest, ) -> AppResult { let base_repo = get_repository(state, base_owner_name, base_repo_name)?; ensure_repo_access(state, Some(acting_user), &base_repo, AccessMode::Read)?; if acting_user.id == base_repo.owner.id { return Err(AppError::Validation( "cannot fork to the same owner".to_string(), )); } if state.db.has_forked_by(base_repo.repo.id, acting_user.id)? { return Err(AppError::Conflict( "repository already forked by this user".to_string(), )); } validate_repo_name(&req.name)?; let repo = state.db.create_repository(NewRepository { owner_id: acting_user.id, owner_name: &acting_user.name, name: &req.name, description: &req.description, default_branch: &base_repo.repo.default_branch, is_private: base_repo.repo.is_private, is_bare: true, is_fork: true, fork_id: base_repo.repo.id, })?; let source_repo_path = repox::repository_path( &state.config.repository.root, &base_repo.owner.name, &base_repo.repo.name, ); let target_repo_path = repox::repository_path(&state.config.repository.root, &acting_user.name, &req.name); if let Err(err) = git::clone_bare_repo_with_binary( &state.config.repository.git_binary, &source_repo_path, &target_repo_path, ) { let _ = std::fs::remove_dir_all(&target_repo_path); let _ = state.db.delete_repository_by_id(repo.id); return Err(err); } Ok(RepositoryWithOwner { repo, owner: acting_user.clone(), }) } pub fn upsert_collaborator( state: &AppState, acting_user: &User, owner_name: &str, repo_name: &str, req: UpsertCollaboratorRequest, ) -> AppResult { let repo = get_repository(state, owner_name, repo_name)?; ensure_repo_owner(acting_user, &repo)?; let collaborator = get_user(state, &req.username)?; if collaborator.id == repo.owner.id { return Err(AppError::Validation( "repository owner cannot be added as collaborator".to_string(), )); } let mode = AccessMode::parse_permission(&req.permission).ok_or_else(|| { AppError::Validation("permission must be one of: read, write, admin".to_string()) })?; state .db .upsert_collaboration(repo.repo.id, collaborator.id, mode)?; Ok(CollaboratorResponse { user: collaborator, mode, }) } pub fn remove_collaborator( state: &AppState, acting_user: &User, owner_name: &str, repo_name: &str, collaborator_name: &str, ) -> AppResult<()> { let repo = get_repository(state, owner_name, repo_name)?; ensure_repo_owner(acting_user, &repo)?; let collaborator = get_user(state, collaborator_name)?; state.db.delete_collaboration(repo.repo.id, collaborator.id) } pub fn list_collaborators( state: &AppState, requesting_user: Option<&User>, owner_name: &str, repo_name: &str, page: i64, per_page: i64, ) -> AppResult> { let repo = get_repository(state, owner_name, repo_name)?; ensure_repo_readable(state, requesting_user, &repo)?; let (limit, offset) = pagination(page, per_page); state.db.list_collaborators(repo.repo.id, limit, offset) } pub fn get_collaborator( state: &AppState, requesting_user: Option<&User>, owner_name: &str, repo_name: &str, collaborator_name: &str, ) -> AppResult { let repo = get_repository(state, owner_name, repo_name)?; ensure_repo_readable(state, requesting_user, &repo)?; let collaborator = get_user(state, collaborator_name)?; state .db .get_collaborator(repo.repo.id, collaborator.id)? .ok_or_else(|| AppError::NotFound(format!("collaborator not found: {collaborator_name}"))) } pub fn list_repositories_by_owner( state: &AppState, requesting_user: Option<&User>, owner_name: &str, query: &str, page: i64, per_page: i64, ) -> AppResult> { let owner = get_user(state, owner_name)?; let (limit, offset) = pagination(page, per_page); let repos = state.db.list_repositories_with_owners_by_owner(owner.id, limit, offset)?; filter_visible_repositories(state, requesting_user, repos, query) } pub fn list_visible_repositories( state: &AppState, requesting_user: Option<&User>, query: &str, page: i64, per_page: i64, ) -> AppResult> { let (limit, offset) = pagination(page, per_page); let repos = state.db.list_repositories_with_owners(limit, offset)?; filter_visible_repositories(state, requesting_user, repos, query) } pub fn repository_permission( state: &AppState, requesting_user: Option<&User>, repo: &RepositoryWithOwner, ) -> AppResult { let mode = effective_access_mode(state, requesting_user, repo)?; Ok(RepositoryPermission { mode, can_read: (mode as i64) >= (AccessMode::Read as i64), can_write: (mode as i64) >= (AccessMode::Write as i64), can_admin: (mode as i64) >= (AccessMode::Admin as i64), is_owner: mode == AccessMode::Owner, }) } pub fn list_branches( state: &AppState, requesting_user: Option<&User>, owner_name: &str, repo_name: &str, ) -> AppResult> { let repo = get_repository(state, owner_name, repo_name)?; ensure_repo_access(state, requesting_user, &repo, AccessMode::Read)?; let repo_path = repox::repository_path(&state.config.repository.root, owner_name, repo_name); let branches = git::list_branches_with_binary(&state.config.repository.git_binary, &repo_path)?; Ok(branches.into_iter().map(|name| Branch { name }).collect()) } pub fn create_pull_request( state: &AppState, acting_user: &User, base_owner_name: &str, base_repo_name: &str, req: CreatePullRequestRequest, ) -> AppResult { if req.title.trim().is_empty() { return Err(AppError::Validation( "pull request title cannot be empty".to_string(), )); } let base_repo = get_repository(state, base_owner_name, base_repo_name)?; ensure_repo_access(state, Some(acting_user), &base_repo, AccessMode::Read)?; let base_repo_path = repox::repository_path( &state.config.repository.root, &base_repo.owner.name, &base_repo.repo.name, ); if !git::branch_exists_with_binary( &state.config.repository.git_binary, &base_repo_path, &req.base_branch, )? { return Err(AppError::NotFound(format!( "base branch not found: {}", req.base_branch ))); } let head_repo = get_repository(state, &req.head_owner, &req.head_repo)?; let same_repo = head_repo.repo.id == base_repo.repo.id; if same_repo && req.head_branch == req.base_branch { return Err(AppError::Validation( "head and base branch cannot be the same".to_string(), )); } if !same_repo && (!head_repo.repo.is_fork || head_repo.repo.fork_id != base_repo.repo.id) { return Err(AppError::NotFound( "head repository is not a fork of base repository".to_string(), )); } ensure_repo_access(state, Some(acting_user), &head_repo, AccessMode::Write)?; let head_repo_path = repox::repository_path( &state.config.repository.root, &head_repo.owner.name, &head_repo.repo.name, ); if !git::branch_exists_with_binary( &state.config.repository.git_binary, &head_repo_path, &req.head_branch, )? { return Err(AppError::NotFound(format!( "head branch not found: {}", req.head_branch ))); } if state .db .get_unmerged_pull_request( head_repo.repo.id, base_repo.repo.id, &req.head_branch, &req.base_branch, )? .is_some() { return Err(AppError::Conflict( "an open pull request already exists for this head/base branch pair".to_string(), )); } let merge_base = git::merge_base_with_binary( &state.config.repository.git_binary, &base_repo_path, &req.base_branch, &head_repo_path, &req.head_branch, )?; if merge_base.is_empty() { return Err(AppError::Validation( "no merge base between branches".to_string(), )); } let status = if git::test_merge_with_binary( &state.config.repository.git_binary, &base_repo_path, &req.base_branch, &head_repo_path, &req.head_branch, )? { PullRequestStatus::Mergeable } else { PullRequestStatus::Conflict }; let pull_request = state.db.create_pull_request(crate::db::NewPullRequest { title: req.title.trim(), body: req.body.trim(), status, head_repo_id: head_repo.repo.id, base_repo_id: base_repo.repo.id, head_user_name: head_repo.owner.name.as_str(), head_branch: req.head_branch.as_str(), base_branch: req.base_branch.as_str(), merge_base: merge_base.as_str(), poster_id: acting_user.id, })?; Ok(PullRequestResponse { pull_request, head_repo, base_repo, }) } pub fn merge_pull_request( state: &AppState, acting_user: &User, base_owner_name: &str, base_repo_name: &str, index: i64, req: MergePullRequestRequest, ) -> AppResult { let base_repo = get_repository(state, base_owner_name, base_repo_name)?; ensure_repo_access(state, Some(acting_user), &base_repo, AccessMode::Write)?; let pull = state .db .get_pull_request_by_base_repo_and_index(base_repo.repo.id, index)? .ok_or_else(|| AppError::NotFound(format!("pull request not found: {index}")))?; if pull.has_merged || pull.is_closed { return Err(AppError::Conflict( "pull request is already closed".to_string(), )); } if pull.status != PullRequestStatus::Mergeable { return Err(AppError::Conflict( "pull request cannot be merged automatically".to_string(), )); } let head_repo = get_repository_by_id(state, pull.head_repo_id)?; let base_repo_path = repox::repository_path( &state.config.repository.root, &base_repo.owner.name, &base_repo.repo.name, ); let head_repo_path = repox::repository_path( &state.config.repository.root, &head_repo.owner.name, &head_repo.repo.name, ); let message = if req.message.trim().is_empty() { format!( "Merge branch '{}' of {}/{} into {}", pull.head_branch, head_repo.owner.name, head_repo.repo.name, pull.base_branch ) } else { req.message.trim().to_string() }; let merged_commit_id = git::merge_pull_request_with_binary( &state.config.repository.git_binary, &base_repo_path, &pull.base_branch, &head_repo_path, &pull.head_branch, &acting_user.name, &acting_user.email, &message, )?; let pull_request = state .db .mark_pull_request_merged(pull.id, &merged_commit_id)?; build_pull_request_response(state, pull_request, Some(head_repo), Some(base_repo)) } pub fn close_pull_request( state: &AppState, acting_user: &User, base_owner_name: &str, base_repo_name: &str, index: i64, ) -> AppResult { let base_repo = get_repository(state, base_owner_name, base_repo_name)?; let pull = state .db .get_pull_request_by_base_repo_and_index(base_repo.repo.id, index)? .ok_or_else(|| AppError::NotFound(format!("pull request not found: {index}")))?; ensure_pull_request_admin(state, acting_user, &base_repo, &pull)?; if pull.has_merged { return Err(AppError::Conflict( "merged pull request cannot be closed".to_string(), )); } if pull.is_closed { return Err(AppError::Conflict( "pull request is already closed".to_string(), )); } let pull_request = state .db .update_pull_request_open_state(pull.id, true, pull.status)?; build_pull_request_response(state, pull_request, None, Some(base_repo)) } pub fn reopen_pull_request( state: &AppState, acting_user: &User, base_owner_name: &str, base_repo_name: &str, index: i64, ) -> AppResult { let base_repo = get_repository(state, base_owner_name, base_repo_name)?; let pull = state .db .get_pull_request_by_base_repo_and_index(base_repo.repo.id, index)? .ok_or_else(|| AppError::NotFound(format!("pull request not found: {index}")))?; ensure_pull_request_admin(state, acting_user, &base_repo, &pull)?; if pull.has_merged { return Err(AppError::Conflict( "merged pull request cannot be reopened".to_string(), )); } if !pull.is_closed { return Err(AppError::Conflict( "pull request is already open".to_string(), )); } if let Some(existing) = state.db.get_unmerged_pull_request( pull.head_repo_id, pull.base_repo_id, &pull.head_branch, &pull.base_branch, )? { if existing.id != pull.id { return Err(AppError::Conflict( "an open pull request already exists for this head/base branch pair".to_string(), )); } } let head_repo = get_repository_by_id(state, pull.head_repo_id)?; let base_repo_path = repox::repository_path( &state.config.repository.root, &base_repo.owner.name, &base_repo.repo.name, ); let head_repo_path = repox::repository_path( &state.config.repository.root, &head_repo.owner.name, &head_repo.repo.name, ); if !git::branch_exists_with_binary( &state.config.repository.git_binary, &base_repo_path, &pull.base_branch, )? { return Err(AppError::NotFound(format!( "base branch not found: {}", pull.base_branch ))); } if !git::branch_exists_with_binary( &state.config.repository.git_binary, &head_repo_path, &pull.head_branch, )? { return Err(AppError::NotFound(format!( "head branch not found: {}", pull.head_branch ))); } let merge_base = git::merge_base_with_binary( &state.config.repository.git_binary, &base_repo_path, &pull.base_branch, &head_repo_path, &pull.head_branch, )?; if merge_base.is_empty() { return Err(AppError::Validation( "no merge base between branches".to_string(), )); } let status = if git::test_merge_with_binary( &state.config.repository.git_binary, &base_repo_path, &pull.base_branch, &head_repo_path, &pull.head_branch, )? { PullRequestStatus::Mergeable } else { PullRequestStatus::Conflict }; let pull_request = state .db .update_pull_request_open_state(pull.id, false, status)?; build_pull_request_response(state, pull_request, Some(head_repo), Some(base_repo)) } pub fn list_pull_requests( state: &AppState, requesting_user: Option<&User>, owner_name: &str, repo_name: &str, page: i64, per_page: i64, ) -> AppResult> { let base_repo = get_repository(state, owner_name, repo_name)?; ensure_repo_access(state, requesting_user, &base_repo, AccessMode::Read)?; let (limit, offset) = pagination(page, per_page); let pulls = state .db .list_pull_requests_by_base_repo(base_repo.repo.id, limit, offset)?; let mut result = Vec::with_capacity(pulls.len()); for pull in pulls { result.push(build_pull_request_response( state, pull, None, Some(base_repo.clone()), )?); } Ok(result) } pub fn get_pull_request_detail( state: &AppState, requesting_user: Option<&User>, owner_name: &str, repo_name: &str, index: i64, ) -> AppResult { let base_repo = get_repository(state, owner_name, repo_name)?; ensure_repo_readable(state, requesting_user, &base_repo)?; let pull_request = state .db .get_pull_request_by_base_repo_and_index(base_repo.repo.id, index)? .ok_or_else(|| AppError::NotFound(format!("pull request not found: {index}")))?; let head_repo = get_repository_by_id(state, pull_request.head_repo_id)?; let compare = build_compare_for_pull_request(state, &base_repo, &head_repo, &pull_request)?; Ok(PullRequestDetailResponse { pull_request, head_repo, base_repo, compare, }) } pub fn compare_repositories( state: &AppState, acting_user: &User, owner_name: &str, repo_name: &str, req: CompareRequest, ) -> AppResult { let base_repo = get_repository(state, owner_name, repo_name)?; ensure_repo_access(state, Some(acting_user), &base_repo, AccessMode::Read)?; let head_repo = get_repository(state, &req.head_owner, &req.head_repo)?; let same_repo = head_repo.repo.id == base_repo.repo.id; if !same_repo && (!head_repo.repo.is_fork || head_repo.repo.fork_id != base_repo.repo.id) { return Err(AppError::NotFound( "head repository is not a fork of base repository".to_string(), )); } ensure_repo_access(state, Some(acting_user), &head_repo, AccessMode::Read)?; build_compare_for_refs( state, &base_repo, &head_repo, req.base.trim(), req.head_branch.trim(), ) } pub fn get_repository( state: &AppState, owner_name: &str, repo_name: &str, ) -> AppResult { let owner = get_user(state, owner_name)?; let repo = state .db .get_repository_by_name(owner.id, repo_name)? .ok_or_else(|| { AppError::NotFound(format!("repository not found: {owner_name}/{repo_name}")) })?; Ok(RepositoryWithOwner { repo, owner }) } pub fn get_repository_by_id(state: &AppState, repo_id: i64) -> AppResult { let repo = state .db .get_repository_by_id(repo_id)? .ok_or_else(|| AppError::NotFound(format!("repository not found: {repo_id}")))?; let owner = state.db.get_user_by_id(repo.owner_id)?.ok_or_else(|| { AppError::NotFound(format!("repository owner not found: {}", repo.owner_id)) })?; Ok(RepositoryWithOwner { repo, owner }) } pub fn get_repository_for_read( state: &AppState, requesting_user: Option<&User>, owner_name: &str, repo_name: &str, ) -> AppResult { let repo = get_repository(state, owner_name, repo_name)?; ensure_repo_readable(state, requesting_user, &repo)?; Ok(repo) } fn build_pull_request_response( state: &AppState, pull_request: PullRequest, head_repo: Option, base_repo: Option, ) -> AppResult { let head_repo = match head_repo { Some(repo) => repo, None => get_repository_by_id(state, pull_request.head_repo_id)?, }; let base_repo = match base_repo { Some(repo) => repo, None => get_repository_by_id(state, pull_request.base_repo_id)?, }; Ok(PullRequestResponse { pull_request, head_repo, base_repo, }) } fn build_compare_for_pull_request( state: &AppState, base_repo: &RepositoryWithOwner, head_repo: &RepositoryWithOwner, pull_request: &PullRequest, ) -> AppResult { if pull_request.has_merged && !pull_request.merged_commit_id.is_empty() { let base_repo_path = repox::repository_path( &state.config.repository.root, &base_repo.owner.name, &base_repo.repo.name, ); let result = git::compare_rev_range_with_binary( &state.config.repository.git_binary, &base_repo_path, &pull_request.merge_base, &pull_request.merged_commit_id, )?; Ok(map_compare_result( pull_request.base_branch.as_str(), pull_request.head_branch.as_str(), if result.files.is_empty() && result.commits.is_empty() { PullRequestStatus::Mergeable } else { pull_request.status }, result, )) } else { build_compare_for_refs( state, base_repo, head_repo, &pull_request.base_branch, &pull_request.head_branch, ) } } fn build_compare_for_refs( state: &AppState, base_repo: &RepositoryWithOwner, head_repo: &RepositoryWithOwner, base_branch: &str, head_branch: &str, ) -> AppResult { let base_repo_path = repox::repository_path( &state.config.repository.root, &base_repo.owner.name, &base_repo.repo.name, ); if !git::branch_exists_with_binary( &state.config.repository.git_binary, &base_repo_path, base_branch, )? { return Err(AppError::NotFound(format!( "base branch not found: {base_branch}" ))); } let head_repo_path = repox::repository_path( &state.config.repository.root, &head_repo.owner.name, &head_repo.repo.name, ); if !git::branch_exists_with_binary( &state.config.repository.git_binary, &head_repo_path, head_branch, )? { return Err(AppError::NotFound(format!( "head branch not found: {head_branch}" ))); } let result = git::compare_refs_with_binary( &state.config.repository.git_binary, &base_repo_path, base_branch, &head_repo_path, head_branch, )?; let status = if result.merge_base == result.head_commit_id { PullRequestStatus::Mergeable } else if git::test_merge_with_binary( &state.config.repository.git_binary, &base_repo_path, base_branch, &head_repo_path, head_branch, )? { PullRequestStatus::Mergeable } else { PullRequestStatus::Conflict }; Ok(map_compare_result(base_branch, head_branch, status, result)) } fn map_compare_result( base_branch: &str, head_branch: &str, status: PullRequestStatus, result: git::CompareResult, ) -> CompareResponse { let is_nothing_to_compare = result.merge_base == result.head_commit_id; CompareResponse { base_branch: base_branch.to_string(), head_branch: head_branch.to_string(), merge_base: result.merge_base, head_commit_id: result.head_commit_id, status, commits: result .commits .into_iter() .map(|commit| CompareCommit { id: commit.id, summary: commit.summary, author_name: commit.author_name, author_email: commit.author_email, }) .collect(), files: result .files .into_iter() .map(|file| CompareFile { path: file.path, additions: file.additions, deletions: file.deletions, }) .collect(), is_nothing_to_compare, } } fn ensure_repo_readable( state: &AppState, requesting_user: Option<&User>, repo: &RepositoryWithOwner, ) -> AppResult<()> { ensure_repo_access(state, requesting_user, repo, AccessMode::Read) } fn ensure_repo_access( state: &AppState, requesting_user: Option<&User>, repo: &RepositoryWithOwner, desired: AccessMode, ) -> AppResult<()> { let mode = effective_access_mode(state, requesting_user, repo)?; if (mode as i64) >= (desired as i64) { return Ok(()); } if repo.repo.is_private { return Err(AppError::NotFound(format!( "repository not found: {}/{}", repo.owner.name, repo.repo.name ))); } Err(AppError::Forbidden("repository access denied".to_string())) } fn ensure_pull_request_admin( state: &AppState, acting_user: &User, base_repo: &RepositoryWithOwner, pull: &PullRequest, ) -> AppResult<()> { let is_writer = state.db.authorize( acting_user.id, base_repo.repo.id, AccessMode::Write, base_repo.owner.id, base_repo.repo.is_private, )?; if is_writer || acting_user.id == pull.poster_id { return Ok(()); } Err(AppError::Forbidden( "pull request status change denied".to_string(), )) } pub fn login_with_password(state: &AppState, login: &str, password: &str) -> AppResult { authenticate_password(state, login, password) } fn authenticate_password(state: &AppState, login: &str, password: &str) -> AppResult { let user = if login.contains('@') { state .db .get_user_by_email(login)? .ok_or_else(|| AppError::Unauthorized("invalid credentials".to_string()))? } else { state .db .get_user_by_username(login)? .ok_or_else(|| AppError::Unauthorized("invalid credentials".to_string()))? }; if !user.is_active { return Err(AppError::Unauthorized("inactive user".to_string())); } let parsed_hash = PasswordHash::new(&user.password_hash) .map_err(|_| AppError::Unauthorized("invalid credentials".to_string()))?; Argon2::default() .verify_password(password.as_bytes(), &parsed_hash) .map_err(|_| AppError::Unauthorized("invalid credentials".to_string()))?; Ok(user) } fn is_token_expired(token: &crate::models::AccessToken) -> bool { crate::db::now_unix() - token.created_unix > TOKEN_TTL_DAYS * 86400 } fn pagination(page: i64, per_page: i64) -> (i64, i64) { let page = page.max(1); let per_page = per_page.clamp(1, MAX_PER_PAGE); let limit = per_page; let offset = (page - 1) * per_page; (limit, offset) } fn validate_user_name(name: &str) -> AppResult<()> { validate_name(name, &[".git", ".wiki"], "username") } fn validate_repo_name(name: &str) -> AppResult<()> { validate_name(name, &[".git", ".wiki"], "repository name") } fn validate_name(name: &str, forbidden_suffixes: &[&str], field: &str) -> AppResult<()> { if name.is_empty() { return Err(AppError::Validation(format!("{field} cannot be empty"))); } if matches!(name, "." | "..") { return Err(AppError::Validation(format!("{field} is reserved"))); } if forbidden_suffixes .iter() .any(|suffix| name.ends_with(suffix)) { return Err(AppError::Validation(format!("{field} has reserved suffix"))); } if !name .chars() .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')) { return Err(AppError::Validation(format!( "{field} must contain only ASCII letters, digits, '-', '_' or '.'" ))); } Ok(()) } fn validate_email(email: &str) -> AppResult<()> { if email.contains('@') && !email.starts_with('@') && !email.ends_with('@') { return Ok(()); } Err(AppError::Validation("email is invalid".to_string())) } fn ensure_repo_owner(acting_user: &User, repo: &RepositoryWithOwner) -> AppResult<()> { if acting_user.id != repo.owner.id { return Err(AppError::Forbidden( "only repository owner can manage collaborators".to_string(), )); } Ok(()) } fn filter_visible_repositories( state: &AppState, requesting_user: Option<&User>, repos: Vec, query: &str, ) -> AppResult> { let query = query.trim().to_ascii_lowercase(); let mut visible = Vec::new(); for repo in repos { let mode = effective_access_mode(state, requesting_user, &repo)?; if mode == AccessMode::None { continue; } if !query.is_empty() && !repository_matches_query(&repo, &query) { continue; } visible.push(repo); } Ok(visible) } fn repository_matches_query(repo: &RepositoryWithOwner, query: &str) -> bool { repo.repo.lower_name.contains(query) || repo.owner.lower_name.contains(query) || repo .repo .description .to_ascii_lowercase() .contains(query) || format!("{}/{}", repo.owner.lower_name, repo.repo.lower_name).contains(query) } fn effective_access_mode( state: &AppState, requesting_user: Option<&User>, repo: &RepositoryWithOwner, ) -> AppResult { let user_id = requesting_user.map(|user| user.id).unwrap_or(0); state .db .access_mode(user_id, repo.repo.id, repo.owner.id, repo.repo.is_private) } fn random_token() -> String { let mut bytes = [0_u8; 32]; OsRng.fill_bytes(&mut bytes); hex::encode(bytes) } fn hash_token(token: &str) -> String { hex::encode(Sha256::digest(token.as_bytes())) }