| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | use forge_core::store::orgs::OrgMemberRole; |
| 9 | use forge_core::{AuditAction, Error as CoreError}; |
| 10 | use serde::Deserialize; |
| 11 | use topcoat::Result; |
| 12 | use topcoat::context::{Cx, app_context}; |
| 13 | use topcoat::cookie::{Cookie, Cookies, SameSite, cookies}; |
| 14 | use topcoat::router::content::Form; |
| 15 | use topcoat::router::error::{forbidden, see_other}; |
| 16 | use topcoat::router::{page, path_param}; |
| 17 | use topcoat::view::{View, component, view}; |
| 18 | |
| 19 | use crate::audit::{self, Actor, Target}; |
| 20 | use crate::auth::{require_csrf, require_user}; |
| 21 | use crate::i18n::repo_admin::{self as g, K}; |
| 22 | use crate::i18n::{self, Lang}; |
| 23 | use crate::pages::repo::repo_head; |
| 24 | use crate::pages::repo_ctx::{RepoCtx, RepoHead, load_repo}; |
| 25 | use crate::state::AppState; |
| 26 | use crate::views::{alert, shell}; |
| 27 | |
| 28 | path_param!(owner); |
| 29 | path_param!(repo); |
| 30 | |
| 31 | |
| 32 | |
| 33 | const NOTICE_COOKIE: &str = "forge_rename_notice"; |
| 34 | |
| 35 | const NOTICES: [(&str, K); 2] = [ |
| 36 | ("renamed", K::RenamedNotice), |
| 37 | ("transferred", K::TransferredNotice), |
| 38 | ]; |
| 39 | |
| 40 | #[derive(Deserialize)] |
| 41 | struct RenameForm { |
| 42 | #[serde(default)] |
| 43 | csrf: String, |
| 44 | #[serde(default)] |
| 45 | name: String, |
| 46 | } |
| 47 | |
| 48 | #[derive(Deserialize)] |
| 49 | struct TransferForm { |
| 50 | #[serde(default)] |
| 51 | csrf: String, |
| 52 | #[serde(default)] |
| 53 | new_owner: String, |
| 54 | #[serde(default)] |
| 55 | confirm: String, |
| 56 | } |
| 57 | |
| 58 | |
| 59 | struct Forms { |
| 60 | rename_name: String, |
| 61 | rename_error: Option<String>, |
| 62 | transfer_owner: String, |
| 63 | transfer_error: Option<String>, |
| 64 | } |
| 65 | |
| 66 | impl Forms { |
| 67 | fn fresh(ctx: &RepoCtx) -> Self { |
| 68 | Self { |
| 69 | rename_name: ctx.repo.name.clone(), |
| 70 | rename_error: None, |
| 71 | transfer_owner: String::new(), |
| 72 | transfer_error: None, |
| 73 | } |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | fn set_notice(cx: &Cx, word: &'static str) { |
| 78 | cookies(cx).add( |
| 79 | Cookie::build((NOTICE_COOKIE, word)) |
| 80 | .path("/") |
| 81 | .http_only(true) |
| 82 | .same_site(SameSite::Lax) |
| 83 | .build(), |
| 84 | ); |
| 85 | } |
| 86 | |
| 87 | |
| 88 | fn take_notice(cx: &Cx) -> Option<K> { |
| 89 | let jar = cookies(cx); |
| 90 | let value = jar.get(NOTICE_COOKIE)?.value().to_owned(); |
| 91 | jar.remove(Cookie::build((NOTICE_COOKIE, "")).path("/")); |
| 92 | NOTICES |
| 93 | .iter() |
| 94 | .find(|(word, _)| *word == value) |
| 95 | .map(|(_, key)| *key) |
| 96 | } |
| 97 | |
| 98 | |
| 99 | #[component] |
| 100 | async fn rename_body( |
| 101 | lang: Lang, |
| 102 | head: RepoHead, |
| 103 | title: String, |
| 104 | csrf: String, |
| 105 | can_delete: bool, |
| 106 | notice: Option<K>, |
| 107 | forms: Forms, |
| 108 | ) -> Result<impl View> { |
| 109 | let rename_action = format!("/{title}/settings/rename"); |
| 110 | let transfer_action = format!("/{title}/settings/transfer"); |
| 111 | let status_href = format!("/{title}/transfer"); |
| 112 | let Forms { |
| 113 | rename_name, |
| 114 | rename_error, |
| 115 | transfer_owner, |
| 116 | transfer_error, |
| 117 | } = forms; |
| 118 | Ok(view! { |
| 119 | repo_head(head: head) |
| 120 | if let Some(key) = notice { |
| 121 | <section class="card notice" role="status">(g::t(lang, key))</section> |
| 122 | } |
| 123 | <section class="card"> |
| 124 | <h2>(g::t(lang, K::RenameRepo))</h2> |
| 125 | <p class="muted small">(g::t(lang, K::RenameHelp))</p> |
| 126 | <form method="post" action=(rename_action.as_str()) class="stack"> |
| 127 | if let Some(message) = rename_error { |
| 128 | alert(message: message.as_str()) |
| 129 | } |
| 130 | <input type="hidden" name="csrf" value=(csrf.as_str())> |
| 131 | <label> |
| 132 | <span>(g::t(lang, K::NewName))</span> |
| 133 | <input type="text" name="name" value=(rename_name.as_str()) autocomplete="off" spellcheck="false" required=(true)> |
| 134 | </label> |
| 135 | <div> |
| 136 | <button type="submit">(g::t(lang, K::RenameRepo))</button> |
| 137 | </div> |
| 138 | </form> |
| 139 | </section> |
| 140 | if can_delete { |
| 141 | <section class="card"> |
| 142 | <h2>(g::t(lang, K::TransferRepo))</h2> |
| 143 | <p class="muted small">(g::t(lang, K::TransferHelp))<a href=(status_href.as_str())>(g::t(lang, K::PendingTransfers))</a></p> |
| 144 | <form method="post" action=(transfer_action.as_str()) class="stack"> |
| 145 | if let Some(message) = transfer_error { |
| 146 | alert(message: message.as_str()) |
| 147 | } |
| 148 | <input type="hidden" name="csrf" value=(csrf.as_str())> |
| 149 | <label> |
| 150 | <span>(g::t(lang, K::NewOwnerLabel))</span> |
| 151 | <input type="text" name="new_owner" value=(transfer_owner.as_str()) autocomplete="off" spellcheck="false" required=(true)> |
| 152 | </label> |
| 153 | <label> |
| 154 | <span>(g::t(lang, K::TypeToConfirmBefore))<code>(title.clone())</code>(g::t(lang, K::TypeToConfirmAfter))</span> |
| 155 | <input type="text" name="confirm" autocomplete="off" spellcheck="false" required=(true)> |
| 156 | </label> |
| 157 | <div> |
| 158 | <button type="submit" class="danger">(g::t(lang, K::TransferRepo))</button> |
| 159 | </div> |
| 160 | </form> |
| 161 | </section> |
| 162 | } |
| 163 | }) |
| 164 | } |
| 165 | |
| 166 | |
| 167 | struct Frame { |
| 168 | page_title: String, |
| 169 | title: String, |
| 170 | can_delete: bool, |
| 171 | head: RepoHead, |
| 172 | } |
| 173 | |
| 174 | impl Frame { |
| 175 | async fn new(ctx: &RepoCtx, state: &AppState, lang: Lang) -> Result<Self> { |
| 176 | let title = format!("{}/{}", ctx.repo.owner_name, ctx.repo.name); |
| 177 | Ok(Self { |
| 178 | page_title: g::page_title(lang, &title), |
| 179 | title, |
| 180 | can_delete: ctx.can_delete(), |
| 181 | head: ctx.head(state, "settings").await?, |
| 182 | }) |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | |
| 187 | |
| 188 | #[page("/{owner}/{repo}/settings/rename")] |
| 189 | async fn rename_get(cx: &Cx) -> Result<impl View> { |
| 190 | let owner = path_param::<Owner>(cx); |
| 191 | let name = path_param::<Repo>(cx); |
| 192 | let ctx = load_repo(cx, owner, name).await?; |
| 193 | let (user, session) = require_user(cx).await?; |
| 194 | if !ctx.can_admin() { |
| 195 | return Err(forbidden().into()); |
| 196 | } |
| 197 | let lang = i18n::lang(cx, Some(&user)); |
| 198 | let notice = take_notice(cx); |
| 199 | let forms = Forms::fresh(&ctx); |
| 200 | let frame = Frame::new(&ctx, app_context::<AppState>(cx), lang).await?; |
| 201 | let csrf = session.csrf_token.clone(); |
| 202 | Ok(view! { |
| 203 | shell(title: frame.page_title.as_str(), viewer: ctx.viewer.as_ref(), |
| 204 | rename_body(lang: lang, head: frame.head, title: frame.title, csrf: csrf, |
| 205 | can_delete: frame.can_delete, |
| 206 | notice: notice, forms: forms)) |
| 207 | }) |
| 208 | } |
| 209 | |
| 210 | |
| 211 | |
| 212 | #[page(POST "/{owner}/{repo}/settings/rename")] |
| 213 | async fn rename_post(cx: &Cx, Form(form): Form<RenameForm>) -> Result<impl View> { |
| 214 | let owner = path_param::<Owner>(cx); |
| 215 | let name = path_param::<Repo>(cx); |
| 216 | let ctx = load_repo(cx, owner, name).await?; |
| 217 | let (user, session) = require_csrf(cx, &form.csrf).await?; |
| 218 | if !ctx.can_admin() { |
| 219 | return Err(forbidden().into()); |
| 220 | } |
| 221 | let lang = i18n::lang(cx, Some(&user)); |
| 222 | let new_name = form.name.trim().to_owned(); |
| 223 | |
| 224 | let outcome = if new_name |
| 225 | .chars() |
| 226 | .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) |
| 227 | { |
| 228 | app_context::<AppState>(cx) |
| 229 | .store |
| 230 | .rename_repo(&ctx.repo.id, &new_name) |
| 231 | .await |
| 232 | } else { |
| 233 | Err(CoreError::InvalidName(g::invalid_name(lang, &new_name))) |
| 234 | }; |
| 235 | |
| 236 | match outcome { |
| 237 | Ok(()) => { |
| 238 | tracing::info!( |
| 239 | actor_id = %user.id, |
| 240 | repo_id = %ctx.repo.id, |
| 241 | old_name = %ctx.repo.name, |
| 242 | new_name = %new_name, |
| 243 | "repository renamed" |
| 244 | ); |
| 245 | audit::record( |
| 246 | &app_context::<AppState>(cx).store, |
| 247 | Some(cx), |
| 248 | Actor::User(&user), |
| 249 | AuditAction::RepoRenamed, |
| 250 | Target { |
| 251 | kind: "repo", |
| 252 | id: &ctx.repo.id, |
| 253 | name: &new_name, |
| 254 | }, |
| 255 | serde_json::json!({ "from": ctx.repo.name }), |
| 256 | ) |
| 257 | .await; |
| 258 | set_notice(cx, "renamed"); |
| 259 | Err(see_other(format!( |
| 260 | "/{}/{new_name}/settings/rename", |
| 261 | ctx.repo.owner_name |
| 262 | )) |
| 263 | .into()) |
| 264 | } |
| 265 | Err(CoreError::InvalidName(message) | CoreError::Conflict(message)) => { |
| 266 | let forms = Forms { |
| 267 | rename_name: new_name, |
| 268 | rename_error: Some(message), |
| 269 | ..Forms::fresh(&ctx) |
| 270 | }; |
| 271 | let frame = Frame::new(&ctx, app_context::<AppState>(cx), lang).await?; |
| 272 | let csrf = session.csrf_token.clone(); |
| 273 | Ok(view! { |
| 274 | shell(title: frame.page_title.as_str(), viewer: ctx.viewer.as_ref(), |
| 275 | rename_body(lang: lang, head: frame.head, title: frame.title, csrf: csrf, |
| 276 | can_delete: frame.can_delete, |
| 277 | notice: None, forms: forms)) |
| 278 | }) |
| 279 | } |
| 280 | Err(other) => Err(other.into()), |
| 281 | } |
| 282 | } |
| 283 | |
| 284 | |
| 285 | |
| 286 | #[page(POST "/{owner}/{repo}/settings/transfer")] |
| 287 | async fn transfer_post(cx: &Cx, Form(form): Form<TransferForm>) -> Result<impl View> { |
| 288 | let owner = path_param::<Owner>(cx); |
| 289 | let name = path_param::<Repo>(cx); |
| 290 | let ctx = load_repo(cx, owner, name).await?; |
| 291 | let (user, session) = require_csrf(cx, &form.csrf).await?; |
| 292 | if !ctx.can_delete() { |
| 293 | return Err(forbidden().into()); |
| 294 | } |
| 295 | let lang = i18n::lang(cx, Some(&user)); |
| 296 | let title = format!("{}/{}", ctx.repo.owner_name, ctx.repo.name); |
| 297 | let target = form.new_owner.trim().to_owned(); |
| 298 | let state = app_context::<AppState>(cx); |
| 299 | |
| 300 | let error: Option<String> = 'attempt: { |
| 301 | if form.confirm.trim() != title { |
| 302 | break 'attempt Some(g::confirm_mismatch(lang, &title)); |
| 303 | } |
| 304 | let Some(new_owner) = state.store.user_by_username(&target).await? else { |
| 305 | break 'attempt Some(g::no_such_user(lang, &target)); |
| 306 | }; |
| 307 | if new_owner.id == ctx.repo.owner_id { |
| 308 | break 'attempt Some(g::t(lang, K::AlreadyOwner).to_owned()); |
| 309 | } |
| 310 | if new_owner.is_org() && !user.is_admin { |
| 311 | let role = state.store.org_member_role(&new_owner.id, &user.id).await?; |
| 312 | if role != Some(OrgMemberRole::Owner) { |
| 313 | break 'attempt Some(g::only_org_owner(lang, &new_owner.username)); |
| 314 | } |
| 315 | } |
| 316 | if !user.is_admin { |
| 317 | |
| 318 | match state |
| 319 | .store |
| 320 | .initiate_transfer(&ctx.repo.id, &new_owner.id, &user.id) |
| 321 | .await |
| 322 | { |
| 323 | Ok(()) => { |
| 324 | tracing::info!( |
| 325 | actor_id = %user.id, |
| 326 | repo_id = %ctx.repo.id, |
| 327 | repo = %title, |
| 328 | new_owner_id = %new_owner.id, |
| 329 | "repository transfer offered" |
| 330 | ); |
| 331 | audit::record( |
| 332 | &state.store, |
| 333 | Some(cx), |
| 334 | Actor::User(&user), |
| 335 | AuditAction::RepoTransferInitiated, |
| 336 | Target { |
| 337 | kind: "repo", |
| 338 | id: &ctx.repo.id, |
| 339 | name: &title, |
| 340 | }, |
| 341 | serde_json::json!({ "to": new_owner.username }), |
| 342 | ) |
| 343 | .await; |
| 344 | return Err(see_other(format!( |
| 345 | "/{}/{}/transfer", |
| 346 | ctx.repo.owner_name, ctx.repo.name |
| 347 | )) |
| 348 | .into()); |
| 349 | } |
| 350 | Err(CoreError::Conflict(message)) => break 'attempt Some(message), |
| 351 | Err(other) => return Err(other.into()), |
| 352 | } |
| 353 | } |
| 354 | match state.store.transfer_repo(&ctx.repo.id, &new_owner.id).await { |
| 355 | Ok(()) => { |
| 356 | tracing::info!( |
| 357 | actor_id = %user.id, |
| 358 | repo_id = %ctx.repo.id, |
| 359 | repo = %title, |
| 360 | new_owner_id = %new_owner.id, |
| 361 | "repository transferred" |
| 362 | ); |
| 363 | audit::record( |
| 364 | &state.store, |
| 365 | Some(cx), |
| 366 | Actor::User(&user), |
| 367 | AuditAction::RepoTransferred, |
| 368 | Target { |
| 369 | kind: "repo", |
| 370 | id: &ctx.repo.id, |
| 371 | name: &title, |
| 372 | }, |
| 373 | serde_json::json!({ "to": new_owner.username }), |
| 374 | ) |
| 375 | .await; |
| 376 | set_notice(cx, "transferred"); |
| 377 | return Err(see_other(format!( |
| 378 | "/{}/{}/settings/rename", |
| 379 | new_owner.username, ctx.repo.name |
| 380 | )) |
| 381 | .into()); |
| 382 | } |
| 383 | Err(CoreError::Conflict(message)) => break 'attempt Some(message), |
| 384 | Err(other) => return Err(other.into()), |
| 385 | } |
| 386 | }; |
| 387 | |
| 388 | let forms = Forms { |
| 389 | transfer_owner: target, |
| 390 | transfer_error: error, |
| 391 | ..Forms::fresh(&ctx) |
| 392 | }; |
| 393 | let frame = Frame::new(&ctx, state, lang).await?; |
| 394 | let csrf = session.csrf_token.clone(); |
| 395 | Ok(view! { |
| 396 | shell(title: frame.page_title.as_str(), viewer: ctx.viewer.as_ref(), |
| 397 | rename_body(lang: lang, head: frame.head, title: frame.title, csrf: csrf, |
| 398 | can_delete: frame.can_delete, |
| 399 | notice: None, forms: forms)) |
| 400 | }) |
| 401 | } |