//! Repo transfer status and actions. `GET /{owner}/{repo}/transfer` shows the open transfer of a //! repo, if any, to viewers who can read it. The POST actions accept, decline and cancel a transfer //! by its repo id under `/-/transfers/{id}/...`: a target who cannot read a private repo can still //! act on it from the inbox (`transfer_list_for`), so those routes check the transfer rules in the //! store and the repo rules here, not the read rule of `load_repo`. //! //! Starting a transfer is on the settings page (see `pages::repo_rename`). Instance admins skip //! acceptance entirely. use forge_core::authz::{Action, authorize}; use forge_core::store::transfers::PendingTransfer; use forge_core::{AuditAction, Error as CoreError}; use serde::Deserialize; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::{SeeOther, forbidden, see_other}; use topcoat::router::{page, path_param, route}; use topcoat::view::{View, component, view}; use crate::audit::{self, Actor, Target}; use crate::auth::{require_csrf, require_user}; use crate::i18n::repo_admin::{self as g, K}; use crate::i18n::{self, Lang}; use crate::pages::repo::repo_head; use crate::pages::repo_ctx::{RepoHead, load_repo}; use crate::state::AppState; use crate::views::{csrf_field, shell}; path_param!(owner); path_param!(repo); path_param!(transfer); #[derive(Deserialize)] struct ActionForm { #[serde(default)] csrf: String, } /// One line describing an open transfer, for the repo page. fn summary_text(lang: Lang, transfer: &PendingTransfer) -> String { let until = transfer.expires_at.split('T').next().unwrap_or_default(); g::transfer_summary( lang, transfer.to_is_org, &transfer.to_owner_name, &transfer.initiated_by_name, until, ) } /// The open transfer and the action forms the viewer may use. Each action is `None` when hidden. #[component] async fn transfer_body( lang: Lang, head: RepoHead, summary: String, accept: Option, decline: Option, cancel: Option, csrf: String, settings: Option, ) -> Result { Ok(view! { repo_head(head: head)

(g::t(lang, K::TransferTitle))

if !summary.is_empty() {

(summary.as_str())

if let Some(action) = accept {
csrf_field(token: csrf.as_str())
} if let Some(action) = decline {
csrf_field(token: csrf.as_str())
} if let Some(action) = cancel {
csrf_field(token: csrf.as_str())
} } if summary.is_empty() {

(g::t(lang, K::NoPendingTransfer))

} if let Some(href) = settings {

(g::t(lang, K::StartTransferLink))

}
}) } /// The transfer status page. Anonymous viewers go to login; viewers who cannot read the repo get /// 404 from `load_repo`. #[page("/{owner}/{repo}/transfer")] async fn transfer_get(cx: &Cx) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let (user, session) = require_user(cx).await?; let lang = i18n::lang(cx, Some(&user)); let state = app_context::(cx); let title = format!("{}/{}", ctx.repo.owner_name, ctx.repo.name); let may_delete = ctx.can_delete(); let pending = state.store.pending_transfer(&ctx.repo.id).await?; let (summary, accept, decline, cancel) = match &pending { Some(transfer) => { let may_accept = state.store.can_accept_transfer(&user.id, transfer).await?; let may_cancel = may_delete || transfer.initiated_by_id == user.id; ( summary_text(lang, transfer), may_accept.then(|| format!("/-/transfers/{}/accept", transfer.repo_id)), may_accept.then(|| format!("/-/transfers/{}/decline", transfer.repo_id)), may_cancel.then(|| format!("/-/transfers/{}/cancel", transfer.repo_id)), ) } None => (String::new(), None, None, None), }; let settings = may_delete.then(|| format!("/{title}/settings/rename")); let head = ctx.head(state, "settings").await?; let page_title = g::transfer_page_title(lang, &title); let csrf = session.csrf_token.clone(); Ok(view! { shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(), transfer_body(lang: lang, head: head, summary: summary, accept: accept, decline: decline, cancel: cancel, csrf: csrf, settings: settings)) }) } /// The open transfer `repo_id`, or `NotFound`. async fn open_transfer(state: &AppState, repo_id: &str) -> Result { Ok(state .store .pending_transfer(repo_id) .await? .ok_or(CoreError::NotFound)?) } /// Accepts the transfer as the target and moves the repo. Then goes to the repo at its new owner. #[route(POST "/-/transfers/{transfer}/accept")] async fn accept_post(cx: &Cx, Form(form): Form) -> Result { let repo_id = path_param::(cx).to_owned(); let (user, _) = require_csrf(cx, &form.csrf).await?; let state = app_context::(cx); let pending = open_transfer(state, &repo_id).await?; state.store.accept_transfer(&repo_id, &user.id).await?; audit::record( &state.store, Some(cx), Actor::User(&user), AuditAction::RepoTransferAccepted, Target { kind: "repo", id: &repo_id, name: &pending.repo_name, }, serde_json::json!({ "from": pending.from_owner_name, "to": pending.to_owner_name }), ) .await; Ok(see_other(format!( "/{}/{}", pending.to_owner_name, pending.repo_name ))) } /// Declines the transfer as one of its target's owners. The repo stays where it is. #[route(POST "/-/transfers/{transfer}/decline")] async fn decline_post(cx: &Cx, Form(form): Form) -> Result { let repo_id = path_param::(cx).to_owned(); let (user, _) = require_csrf(cx, &form.csrf).await?; let state = app_context::(cx); let pending = open_transfer(state, &repo_id).await?; state.store.decline_transfer(&repo_id, &user.id).await?; audit::record( &state.store, Some(cx), Actor::User(&user), AuditAction::RepoTransferDeclined, Target { kind: "repo", id: &repo_id, name: &pending.repo_name, }, serde_json::json!({ "from": pending.from_owner_name, "to": pending.to_owner_name }), ) .await; Ok(see_other("/notifications")) } /// Cancels the transfer. The initiator may, and so may anyone who may delete the repo. #[route(POST "/-/transfers/{transfer}/cancel")] async fn cancel_post(cx: &Cx, Form(form): Form) -> Result { let repo_id = path_param::(cx).to_owned(); let (user, _) = require_csrf(cx, &form.csrf).await?; let state = app_context::(cx); let pending = open_transfer(state, &repo_id).await?; let repo = state .store .repo_by_id(&repo_id) .await? .ok_or(CoreError::NotFound)?; let org_role = state .store .org_repo_role(&repo.owner_id, &user.id, &repo.id) .await?; let may_delete = authorize(Some(&user), &repo, None, org_role, Action::Delete).is_ok(); if !may_delete && pending.initiated_by_id != user.id { return Err(forbidden().into()); } state.store.cancel_transfer(&repo_id).await?; audit::record( &state.store, Some(cx), Actor::User(&user), AuditAction::RepoTransferCancelled, Target { kind: "repo", id: &repo_id, name: &pending.repo_name, }, serde_json::json!({ "from": pending.from_owner_name, "to": pending.to_owner_name }), ) .await; Ok(see_other(format!( "/{}/{}/transfer", pending.from_owner_name, pending.repo_name ))) } /// One inbox line: a transfer waiting for the viewer, with its accept and decline forms. struct InboxRow { line: String, accept: String, decline: String, } /// The inbox section listing open transfers the viewer may accept. Renders nothing when there are /// none. The forms post to `/-/transfers/{id}/...`, so they work for a target who cannot read a /// private repo. #[component] pub(crate) async fn transfer_list_for( lang: Lang, transfers: Vec, csrf: String, ) -> Result { let rows: Vec = transfers .into_iter() .map(|t| InboxRow { line: g::inbox_line( lang, &t.from_owner_name, &t.repo_name, &t.to_owner_name, &t.initiated_by_name, ), accept: format!("/-/transfers/{}/accept", t.repo_id), decline: format!("/-/transfers/{}/decline", t.repo_id), }) .collect(); Ok(view! { if !rows.is_empty() {

(g::t(lang, K::RepoTransfers))

    for row in rows.into_iter() {
  • (row.line.as_str())
    csrf_field(token: csrf.as_str())
    csrf_field(token: csrf.as_str())
  • }
} }) }