| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194 |
- 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<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 = 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<CreateAccessTokenResponse> {
- 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<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,
- page: i64,
- per_page: i64,
- ) -> AppResult<Vec<AccessTokenResponse>> {
- 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<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()))?;
- 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<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,
- page: i64,
- per_page: i64,
- ) -> AppResult<Vec<CollaboratorResponse>> {
- 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<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,
- page: i64,
- per_page: i64,
- ) -> AppResult<Vec<RepositoryWithOwner>> {
- 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<Vec<RepositoryWithOwner>> {
- 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<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,
- page: i64,
- per_page: i64,
- ) -> AppResult<Vec<PullRequestResponse>> {
- 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<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::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<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> {
- authenticate_password(state, login, password)
- }
- fn authenticate_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 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<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()))
- }
|