//! Page: rename and transfer a repo (`/{owner}/{repo}/settings/rename`). Renaming needs `Action::Admin` //! (owner, instance admin, or an Admin collaborator). Transferring needs `Action::Delete` (owner or //! instance admin) and a typed `owner/name` confirmation. A non-admin transfer is only offered: the //! target accepts it on `pages::repo_transfer`, and an instance admin's moves at once. The old //! owner/name keeps working through a redirect (see `store::move_repo` and `pages::repo_ctx`). The //! bare repo on disk never moves. use forge_core::store::orgs::OrgMemberRole; use forge_core::{AuditAction, Error as CoreError}; use serde::Deserialize; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::cookie::{Cookie, Cookies, SameSite, cookies}; use topcoat::router::content::Form; use topcoat::router::error::{forbidden, see_other}; use topcoat::router::{page, path_param}; 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::{RepoCtx, RepoHead, load_repo}; use crate::state::AppState; use crate::views::{alert, shell}; path_param!(owner); path_param!(repo); /// Cookie that carries a one-shot notice to the next rename page view. Its value is one of the fixed /// words in `NOTICES`, never user text. const NOTICE_COOKIE: &str = "forge_rename_notice"; const NOTICES: [(&str, K); 2] = [ ("renamed", K::RenamedNotice), ("transferred", K::TransferredNotice), ]; #[derive(Deserialize)] struct RenameForm { #[serde(default)] csrf: String, #[serde(default)] name: String, } #[derive(Deserialize)] struct TransferForm { #[serde(default)] csrf: String, #[serde(default)] new_owner: String, #[serde(default)] confirm: String, } /// What the two forms show: the drafts the viewer typed and any error for each form. struct Forms { rename_name: String, rename_error: Option, transfer_owner: String, transfer_error: Option, } impl Forms { fn fresh(ctx: &RepoCtx) -> Self { Self { rename_name: ctx.repo.name.clone(), rename_error: None, transfer_owner: String::new(), transfer_error: None, } } } fn set_notice(cx: &Cx, word: &'static str) { cookies(cx).add( Cookie::build((NOTICE_COOKIE, word)) .path("/") .http_only(true) .same_site(SameSite::Lax) .build(), ); } /// Reads and clears the notice cookie. Only the fixed words are shown. fn take_notice(cx: &Cx) -> Option { let jar = cookies(cx); let value = jar.get(NOTICE_COOKIE)?.value().to_owned(); jar.remove(Cookie::build((NOTICE_COOKIE, "")).path("/")); NOTICES .iter() .find(|(word, _)| *word == value) .map(|(_, key)| *key) } /// Rename and transfer forms. The transfer section is shown only when the viewer may delete the repo. #[component] async fn rename_body( lang: Lang, head: RepoHead, title: String, csrf: String, can_delete: bool, notice: Option, forms: Forms, ) -> Result { let rename_action = format!("/{title}/settings/rename"); let transfer_action = format!("/{title}/settings/transfer"); let status_href = format!("/{title}/transfer"); let Forms { rename_name, rename_error, transfer_owner, transfer_error, } = forms; Ok(view! { repo_head(head: head) if let Some(key) = notice {
(g::t(lang, key))
}

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

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

if let Some(message) = rename_error { alert(message: message.as_str()) }
if can_delete {

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

(g::t(lang, K::TransferHelp))(g::t(lang, K::PendingTransfers))

if let Some(message) = transfer_error { alert(message: message.as_str()) }
} }) } /// The repo fields the page shows, copied out of the context so the view borrows nothing from it. struct Frame { page_title: String, title: String, can_delete: bool, head: RepoHead, } impl Frame { async fn new(ctx: &RepoCtx, state: &AppState, lang: Lang) -> Result { let title = format!("{}/{}", ctx.repo.owner_name, ctx.repo.name); Ok(Self { page_title: g::page_title(lang, &title), title, can_delete: ctx.can_delete(), head: ctx.head(state, "settings").await?, }) } } /// The rename and transfer page. Anonymous viewers go to login; signed-in viewers without the /// Admin right get 403. #[page("/{owner}/{repo}/settings/rename")] async fn rename_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?; if !ctx.can_admin() { return Err(forbidden().into()); } let lang = i18n::lang(cx, Some(&user)); let notice = take_notice(cx); let forms = Forms::fresh(&ctx); let frame = Frame::new(&ctx, app_context::(cx), lang).await?; let csrf = session.csrf_token.clone(); Ok(view! { shell(title: frame.page_title.as_str(), viewer: ctx.viewer.as_ref(), rename_body(lang: lang, head: frame.head, title: frame.title, csrf: csrf, can_delete: frame.can_delete, notice: notice, forms: forms)) }) } /// Renames the repo. The store validates the name with `validate_name`; an invalid name or a name /// taken by another repo of the owner re-renders the form with the message. #[page(POST "/{owner}/{repo}/settings/rename")] async fn rename_post(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let (user, session) = require_csrf(cx, &form.csrf).await?; if !ctx.can_admin() { return Err(forbidden().into()); } let lang = i18n::lang(cx, Some(&user)); let new_name = form.name.trim().to_owned(); // Only ASCII is accepted so the new name is safe in URLs and clone addresses. let outcome = if new_name .chars() .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) { app_context::(cx) .store .rename_repo(&ctx.repo.id, &new_name) .await } else { Err(CoreError::InvalidName(g::invalid_name(lang, &new_name))) }; match outcome { Ok(()) => { tracing::info!( actor_id = %user.id, repo_id = %ctx.repo.id, old_name = %ctx.repo.name, new_name = %new_name, "repository renamed" ); audit::record( &app_context::(cx).store, Some(cx), Actor::User(&user), AuditAction::RepoRenamed, Target { kind: "repo", id: &ctx.repo.id, name: &new_name, }, serde_json::json!({ "from": ctx.repo.name }), ) .await; set_notice(cx, "renamed"); Err(see_other(format!( "/{}/{new_name}/settings/rename", ctx.repo.owner_name )) .into()) } Err(CoreError::InvalidName(message) | CoreError::Conflict(message)) => { let forms = Forms { rename_name: new_name, rename_error: Some(message), ..Forms::fresh(&ctx) }; let frame = Frame::new(&ctx, app_context::(cx), lang).await?; let csrf = session.csrf_token.clone(); Ok(view! { shell(title: frame.page_title.as_str(), viewer: ctx.viewer.as_ref(), rename_body(lang: lang, head: frame.head, title: frame.title, csrf: csrf, can_delete: frame.can_delete, notice: None, forms: forms)) }) } Err(other) => Err(other.into()), } } /// Transfers the repo to the user named in `new_owner`. Needs `Action::Delete` and the exact /// `owner/name` confirmation. The store removes the new owner's collaborator row. #[page(POST "/{owner}/{repo}/settings/transfer")] async fn transfer_post(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let (user, session) = require_csrf(cx, &form.csrf).await?; if !ctx.can_delete() { return Err(forbidden().into()); } let lang = i18n::lang(cx, Some(&user)); let title = format!("{}/{}", ctx.repo.owner_name, ctx.repo.name); let target = form.new_owner.trim().to_owned(); let state = app_context::(cx); let error: Option = 'attempt: { if form.confirm.trim() != title { break 'attempt Some(g::confirm_mismatch(lang, &title)); } let Some(new_owner) = state.store.user_by_username(&target).await? else { break 'attempt Some(g::no_such_user(lang, &target)); }; if new_owner.id == ctx.repo.owner_id { break 'attempt Some(g::t(lang, K::AlreadyOwner).to_owned()); } if new_owner.is_org() && !user.is_admin { let role = state.store.org_member_role(&new_owner.id, &user.id).await?; if role != Some(OrgMemberRole::Owner) { break 'attempt Some(g::only_org_owner(lang, &new_owner.username)); } } if !user.is_admin { // The target must accept first, so the repo does not move yet. match state .store .initiate_transfer(&ctx.repo.id, &new_owner.id, &user.id) .await { Ok(()) => { tracing::info!( actor_id = %user.id, repo_id = %ctx.repo.id, repo = %title, new_owner_id = %new_owner.id, "repository transfer offered" ); audit::record( &state.store, Some(cx), Actor::User(&user), AuditAction::RepoTransferInitiated, Target { kind: "repo", id: &ctx.repo.id, name: &title, }, serde_json::json!({ "to": new_owner.username }), ) .await; return Err(see_other(format!( "/{}/{}/transfer", ctx.repo.owner_name, ctx.repo.name )) .into()); } Err(CoreError::Conflict(message)) => break 'attempt Some(message), Err(other) => return Err(other.into()), } } match state.store.transfer_repo(&ctx.repo.id, &new_owner.id).await { Ok(()) => { tracing::info!( actor_id = %user.id, repo_id = %ctx.repo.id, repo = %title, new_owner_id = %new_owner.id, "repository transferred" ); audit::record( &state.store, Some(cx), Actor::User(&user), AuditAction::RepoTransferred, Target { kind: "repo", id: &ctx.repo.id, name: &title, }, serde_json::json!({ "to": new_owner.username }), ) .await; set_notice(cx, "transferred"); return Err(see_other(format!( "/{}/{}/settings/rename", new_owner.username, ctx.repo.name )) .into()); } Err(CoreError::Conflict(message)) => break 'attempt Some(message), Err(other) => return Err(other.into()), } }; let forms = Forms { transfer_owner: target, transfer_error: error, ..Forms::fresh(&ctx) }; let frame = Frame::new(&ctx, state, lang).await?; let csrf = session.csrf_token.clone(); Ok(view! { shell(title: frame.page_title.as_str(), viewer: ctx.viewer.as_ref(), rename_body(lang: lang, head: frame.head, title: frame.title, csrf: csrf, can_delete: frame.can_delete, notice: None, forms: forms)) }) }