| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163 |
- 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,
- };
- pub fn create_user(state: &AppState, req: CreateUserRequest) -> AppResult<User> {
- 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<User> {
- 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<bool> {
- Ok(state.db.user_count()? == 0)
- }
- pub fn login(state: &AppState, req: LoginRequest) -> AppResult<LoginResponse> {
- let user = if req.login.contains('@') {
- state
- .db
- .get_user_by_email(&req.login)?
- .ok_or_else(|| AppError::Unauthorized("invalid credentials".to_string()))?
- } else {
- state
- .db
- .get_user_by_username(&req.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(req.password.as_bytes(), &parsed_hash)
- .map_err(|_| AppError::Unauthorized("invalid credentials".to_string()))?;
- let token = issue_access_token(
- state,
- user.id,
- CreateAccessTokenRequest {
- name: "login".to_string(),
- },
- )?;
- Ok(LoginResponse {
- token: token.token,
- user,
- })
- }
- pub fn issue_access_token(
- state: &AppState,
- user_id: i64,
- req: CreateAccessTokenRequest,
- ) -> AppResult<CreateAccessTokenResponse> {
- 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) -> AppResult<Vec<AccessTokenResponse>> {
- let tokens = state.db.list_access_tokens_by_user(user_id)?;
- 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<User> {
- 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()))?;
- let _ = state.db.touch_access_token(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<User> {
- 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<RepositoryWithOwner> {
- 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<RepositoryWithOwner> {
- 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<CollaboratorResponse> {
- 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,
- ) -> AppResult<Vec<CollaboratorResponse>> {
- let repo = get_repository(state, owner_name, repo_name)?;
- ensure_repo_readable(state, requesting_user, &repo)?;
- state.db.list_collaborators(repo.repo.id)
- }
- pub fn get_collaborator(
- state: &AppState,
- requesting_user: Option<&User>,
- owner_name: &str,
- repo_name: &str,
- collaborator_name: &str,
- ) -> AppResult<CollaboratorResponse> {
- 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,
- ) -> AppResult<Vec<RepositoryWithOwner>> {
- let owner = get_user(state, owner_name)?;
- let repos = state.db.list_repositories_with_owners_by_owner(owner.id)?;
- filter_visible_repositories(state, requesting_user, repos, query)
- }
- pub fn list_visible_repositories(
- state: &AppState,
- requesting_user: Option<&User>,
- query: &str,
- ) -> AppResult<Vec<RepositoryWithOwner>> {
- let repos = state.db.list_repositories_with_owners()?;
- filter_visible_repositories(state, requesting_user, repos, query)
- }
- pub fn repository_permission(
- state: &AppState,
- requesting_user: Option<&User>,
- repo: &RepositoryWithOwner,
- ) -> AppResult<RepositoryPermission> {
- 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<Vec<Branch>> {
- 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<PullRequestResponse> {
- 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<PullRequestResponse> {
- 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<PullRequestResponse> {
- 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<PullRequestResponse> {
- 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,
- ) -> AppResult<Vec<PullRequestResponse>> {
- let base_repo = get_repository(state, owner_name, repo_name)?;
- ensure_repo_access(state, requesting_user, &base_repo, AccessMode::Read)?;
- let pulls = state
- .db
- .list_pull_requests_by_base_repo(base_repo.repo.id)?;
- 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<PullRequestDetailResponse> {
- 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<CompareResponse> {
- 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::Write)?;
- 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<RepositoryWithOwner> {
- 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<RepositoryWithOwner> {
- 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<RepositoryWithOwner> {
- 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<RepositoryWithOwner>,
- base_repo: Option<RepositoryWithOwner>,
- ) -> AppResult<PullRequestResponse> {
- 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<CompareResponse> {
- 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<CompareResponse> {
- 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<User> {
- 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 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<RepositoryWithOwner>,
- query: &str,
- ) -> AppResult<Vec<RepositoryWithOwner>> {
- 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<AccessMode> {
- 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()))
- }
|