| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | |
| 10 | use forge_core::authz::{Action, authorize}; |
| 11 | use forge_core::store::transfers::PendingTransfer; |
| 12 | use forge_core::{AuditAction, Error as CoreError}; |
| 13 | use serde::Deserialize; |
| 14 | use topcoat::Result; |
| 15 | use topcoat::context::{Cx, app_context}; |
| 16 | use topcoat::router::content::Form; |
| 17 | use topcoat::router::error::{SeeOther, forbidden, see_other}; |
| 18 | use topcoat::router::{page, path_param, route}; |
| 19 | use topcoat::view::{View, component, view}; |
| 20 | |
| 21 | use crate::audit::{self, Actor, Target}; |
| 22 | use crate::auth::{require_csrf, require_user}; |
| 23 | use crate::i18n::repo_admin::{self as g, K}; |
| 24 | use crate::i18n::{self, Lang}; |
| 25 | use crate::pages::repo::repo_head; |
| 26 | use crate::pages::repo_ctx::{RepoHead, load_repo}; |
| 27 | use crate::state::AppState; |
| 28 | use crate::views::{csrf_field, shell}; |
| 29 | |
| 30 | path_param!(owner); |
| 31 | path_param!(repo); |
| 32 | path_param!(transfer); |
| 33 | |
| 34 | #[derive(Deserialize)] |
| 35 | struct ActionForm { |
| 36 | #[serde(default)] |
| 37 | csrf: String, |
| 38 | } |
| 39 | |
| 40 | |
| 41 | fn summary_text(lang: Lang, transfer: &PendingTransfer) -> String { |
| 42 | let until = transfer.expires_at.split('T').next().unwrap_or_default(); |
| 43 | g::transfer_summary( |
| 44 | lang, |
| 45 | transfer.to_is_org, |
| 46 | &transfer.to_owner_name, |
| 47 | &transfer.initiated_by_name, |
| 48 | until, |
| 49 | ) |
| 50 | } |
| 51 | |
| 52 | |
| 53 | #[component] |
| 54 | async fn transfer_body( |
| 55 | lang: Lang, |
| 56 | head: RepoHead, |
| 57 | summary: String, |
| 58 | accept: Option<String>, |
| 59 | decline: Option<String>, |
| 60 | cancel: Option<String>, |
| 61 | csrf: String, |
| 62 | settings: Option<String>, |
| 63 | ) -> Result<impl View> { |
| 64 | Ok(view! { |
| 65 | repo_head(head: head) |
| 66 | <section class="card"> |
| 67 | <h2>(g::t(lang, K::TransferTitle))</h2> |
| 68 | if !summary.is_empty() { |
| 69 | <p>(summary.as_str())</p> |
| 70 | if let Some(action) = accept { |
| 71 | <form method="post" action=(action.as_str()) class="stack"> |
| 72 | csrf_field(token: csrf.as_str()) |
| 73 | <div> |
| 74 | <button type="submit">(g::t(lang, K::AcceptTransfer))</button> |
| 75 | </div> |
| 76 | </form> |
| 77 | } |
| 78 | if let Some(action) = decline { |
| 79 | <form method="post" action=(action.as_str()) class="stack"> |
| 80 | csrf_field(token: csrf.as_str()) |
| 81 | <div> |
| 82 | <button type="submit" class="danger">(g::t(lang, K::DeclineTransfer))</button> |
| 83 | </div> |
| 84 | </form> |
| 85 | } |
| 86 | if let Some(action) = cancel { |
| 87 | <form method="post" action=(action.as_str()) class="stack"> |
| 88 | csrf_field(token: csrf.as_str()) |
| 89 | <div> |
| 90 | <button type="submit" class="danger">(g::t(lang, K::CancelTransfer))</button> |
| 91 | </div> |
| 92 | </form> |
| 93 | } |
| 94 | } |
| 95 | if summary.is_empty() { |
| 96 | <p class="muted">(g::t(lang, K::NoPendingTransfer))</p> |
| 97 | } |
| 98 | if let Some(href) = settings { |
| 99 | <p class="muted small"><a href=(href.as_str())>(g::t(lang, K::StartTransferLink))</a></p> |
| 100 | } |
| 101 | </section> |
| 102 | }) |
| 103 | } |
| 104 | |
| 105 | |
| 106 | |
| 107 | #[page("/{owner}/{repo}/transfer")] |
| 108 | async fn transfer_get(cx: &Cx) -> Result<impl View> { |
| 109 | let owner = path_param::<Owner>(cx); |
| 110 | let name = path_param::<Repo>(cx); |
| 111 | let ctx = load_repo(cx, owner, name).await?; |
| 112 | let (user, session) = require_user(cx).await?; |
| 113 | let lang = i18n::lang(cx, Some(&user)); |
| 114 | let state = app_context::<AppState>(cx); |
| 115 | let title = format!("{}/{}", ctx.repo.owner_name, ctx.repo.name); |
| 116 | let may_delete = ctx.can_delete(); |
| 117 | let pending = state.store.pending_transfer(&ctx.repo.id).await?; |
| 118 | |
| 119 | let (summary, accept, decline, cancel) = match &pending { |
| 120 | Some(transfer) => { |
| 121 | let may_accept = state.store.can_accept_transfer(&user.id, transfer).await?; |
| 122 | let may_cancel = may_delete || transfer.initiated_by_id == user.id; |
| 123 | ( |
| 124 | summary_text(lang, transfer), |
| 125 | may_accept.then(|| format!("/-/transfers/{}/accept", transfer.repo_id)), |
| 126 | may_accept.then(|| format!("/-/transfers/{}/decline", transfer.repo_id)), |
| 127 | may_cancel.then(|| format!("/-/transfers/{}/cancel", transfer.repo_id)), |
| 128 | ) |
| 129 | } |
| 130 | None => (String::new(), None, None, None), |
| 131 | }; |
| 132 | let settings = may_delete.then(|| format!("/{title}/settings/rename")); |
| 133 | let head = ctx.head(state, "settings").await?; |
| 134 | let page_title = g::transfer_page_title(lang, &title); |
| 135 | let csrf = session.csrf_token.clone(); |
| 136 | Ok(view! { |
| 137 | shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(), |
| 138 | transfer_body(lang: lang, head: head, summary: summary, accept: accept, decline: decline, |
| 139 | cancel: cancel, csrf: csrf, settings: settings)) |
| 140 | }) |
| 141 | } |
| 142 | |
| 143 | |
| 144 | async fn open_transfer(state: &AppState, repo_id: &str) -> Result<PendingTransfer> { |
| 145 | Ok(state |
| 146 | .store |
| 147 | .pending_transfer(repo_id) |
| 148 | .await? |
| 149 | .ok_or(CoreError::NotFound)?) |
| 150 | } |
| 151 | |
| 152 | |
| 153 | #[route(POST "/-/transfers/{transfer}/accept")] |
| 154 | async fn accept_post(cx: &Cx, Form(form): Form<ActionForm>) -> Result<SeeOther> { |
| 155 | let repo_id = path_param::<Transfer>(cx).to_owned(); |
| 156 | let (user, _) = require_csrf(cx, &form.csrf).await?; |
| 157 | let state = app_context::<AppState>(cx); |
| 158 | let pending = open_transfer(state, &repo_id).await?; |
| 159 | state.store.accept_transfer(&repo_id, &user.id).await?; |
| 160 | audit::record( |
| 161 | &state.store, |
| 162 | Some(cx), |
| 163 | Actor::User(&user), |
| 164 | AuditAction::RepoTransferAccepted, |
| 165 | Target { |
| 166 | kind: "repo", |
| 167 | id: &repo_id, |
| 168 | name: &pending.repo_name, |
| 169 | }, |
| 170 | serde_json::json!({ "from": pending.from_owner_name, "to": pending.to_owner_name }), |
| 171 | ) |
| 172 | .await; |
| 173 | Ok(see_other(format!( |
| 174 | "/{}/{}", |
| 175 | pending.to_owner_name, pending.repo_name |
| 176 | ))) |
| 177 | } |
| 178 | |
| 179 | |
| 180 | #[route(POST "/-/transfers/{transfer}/decline")] |
| 181 | async fn decline_post(cx: &Cx, Form(form): Form<ActionForm>) -> Result<SeeOther> { |
| 182 | let repo_id = path_param::<Transfer>(cx).to_owned(); |
| 183 | let (user, _) = require_csrf(cx, &form.csrf).await?; |
| 184 | let state = app_context::<AppState>(cx); |
| 185 | let pending = open_transfer(state, &repo_id).await?; |
| 186 | state.store.decline_transfer(&repo_id, &user.id).await?; |
| 187 | audit::record( |
| 188 | &state.store, |
| 189 | Some(cx), |
| 190 | Actor::User(&user), |
| 191 | AuditAction::RepoTransferDeclined, |
| 192 | Target { |
| 193 | kind: "repo", |
| 194 | id: &repo_id, |
| 195 | name: &pending.repo_name, |
| 196 | }, |
| 197 | serde_json::json!({ "from": pending.from_owner_name, "to": pending.to_owner_name }), |
| 198 | ) |
| 199 | .await; |
| 200 | Ok(see_other("/notifications")) |
| 201 | } |
| 202 | |
| 203 | |
| 204 | #[route(POST "/-/transfers/{transfer}/cancel")] |
| 205 | async fn cancel_post(cx: &Cx, Form(form): Form<ActionForm>) -> Result<SeeOther> { |
| 206 | let repo_id = path_param::<Transfer>(cx).to_owned(); |
| 207 | let (user, _) = require_csrf(cx, &form.csrf).await?; |
| 208 | let state = app_context::<AppState>(cx); |
| 209 | let pending = open_transfer(state, &repo_id).await?; |
| 210 | let repo = state |
| 211 | .store |
| 212 | .repo_by_id(&repo_id) |
| 213 | .await? |
| 214 | .ok_or(CoreError::NotFound)?; |
| 215 | let org_role = state |
| 216 | .store |
| 217 | .org_repo_role(&repo.owner_id, &user.id, &repo.id) |
| 218 | .await?; |
| 219 | let may_delete = authorize(Some(&user), &repo, None, org_role, Action::Delete).is_ok(); |
| 220 | if !may_delete && pending.initiated_by_id != user.id { |
| 221 | return Err(forbidden().into()); |
| 222 | } |
| 223 | state.store.cancel_transfer(&repo_id).await?; |
| 224 | audit::record( |
| 225 | &state.store, |
| 226 | Some(cx), |
| 227 | Actor::User(&user), |
| 228 | AuditAction::RepoTransferCancelled, |
| 229 | Target { |
| 230 | kind: "repo", |
| 231 | id: &repo_id, |
| 232 | name: &pending.repo_name, |
| 233 | }, |
| 234 | serde_json::json!({ "from": pending.from_owner_name, "to": pending.to_owner_name }), |
| 235 | ) |
| 236 | .await; |
| 237 | Ok(see_other(format!( |
| 238 | "/{}/{}/transfer", |
| 239 | pending.from_owner_name, pending.repo_name |
| 240 | ))) |
| 241 | } |
| 242 | |
| 243 | |
| 244 | struct InboxRow { |
| 245 | line: String, |
| 246 | accept: String, |
| 247 | decline: String, |
| 248 | } |
| 249 | |
| 250 | |
| 251 | |
| 252 | |
| 253 | #[component] |
| 254 | pub(crate) async fn transfer_list_for( |
| 255 | lang: Lang, |
| 256 | transfers: Vec<PendingTransfer>, |
| 257 | csrf: String, |
| 258 | ) -> Result<impl View> { |
| 259 | let rows: Vec<InboxRow> = transfers |
| 260 | .into_iter() |
| 261 | .map(|t| InboxRow { |
| 262 | line: g::inbox_line( |
| 263 | lang, |
| 264 | &t.from_owner_name, |
| 265 | &t.repo_name, |
| 266 | &t.to_owner_name, |
| 267 | &t.initiated_by_name, |
| 268 | ), |
| 269 | accept: format!("/-/transfers/{}/accept", t.repo_id), |
| 270 | decline: format!("/-/transfers/{}/decline", t.repo_id), |
| 271 | }) |
| 272 | .collect(); |
| 273 | Ok(view! { |
| 274 | if !rows.is_empty() { |
| 275 | <section class="card notif-group"> |
| 276 | <header class="notif-group-head"> |
| 277 | <h2>(g::t(lang, K::RepoTransfers))</h2> |
| 278 | </header> |
| 279 | <ul class="notif-list"> |
| 280 | for row in rows.into_iter() { |
| 281 | <li> |
| 282 | <span class="notif-title">(row.line.as_str())</span> |
| 283 | <form method="post" action=(row.accept.as_str()) class="notif-inline-form"> |
| 284 | csrf_field(token: csrf.as_str()) |
| 285 | <button type="submit">(g::t(lang, K::Accept))</button> |
| 286 | </form> |
| 287 | <form method="post" action=(row.decline.as_str()) class="notif-inline-form"> |
| 288 | csrf_field(token: csrf.as_str()) |
| 289 | <button type="submit" class="link-button">(g::t(lang, K::Decline))</button> |
| 290 | </form> |
| 291 | </li> |
| 292 | } |
| 293 | </ul> |
| 294 | </section> |
| 295 | } |
| 296 | }) |
| 297 | } |