collin/anvil · 02f1ec0b
Delete a repository from its settings page
Collin Richards · 2026-08-24 08:57 UTC · 02f1ec0bc18ffbfb9bd6c637c25a4e9f80388433 · parent 6410f75b · browse files
modifiedTODO.md+1 −7
| 1 | 1 | # Todo | |
| 2 | 2 | ||
| 3 | - | ## Add the ability to delete a repo | |
| 4 | - | ||
| 5 | - | Deleted no reboux should only be done through the settings.Menu of a repo must be the.Repo owner?And there should be some sort of confirmation dialogue that prevents it from being done on accident.Such as typing in the name of the repo.When you try to delete it | |
| 6 | - | ||
| 7 | 3 | ||
| 8 | 4 | # Backlog | |
| 9 | 5 | ||
| ⋯ 21 unchanged lines | |||
| 31 | 27 | references) and/or a per-attachment delete action — the recourse once a repo | |
| 32 | 28 | hits its quota. Deferred: deletion is destructive and "orphaned" is fuzzy | |
| 33 | 29 | (tip-only vs any-ref), so it wants its own design pass | |
| 34 | - | - [ ] remove a repo's attachment + artifact dirs on repo delete — blocked: there | |
| 35 | - | is no repo-delete path yet (only the create-rollback uses it) | |
| 36 | 30 | - [ ] admin usage: per-repo drill-down, and a cheap cached/periodic variant if | |
| 37 | 31 | the on-demand disk walk gets slow on large instances | |
| 38 | 32 | - [ ] periodic disk usage cache: run `usage::compute()` on a timer (e.g., hourly) | |
| ⋯ 10 unchanged lines | |||
| 49 | 43 | - [ ] secrets follow-ups (docs/secrets.md): authenticate `anvild secret` with an | |
| 50 | 44 | ssh signature instead of the account password; per-step rather than per- | |
| 51 | 45 | pipeline scoping; `ssh-rsa` recipients (needs an RSA-OAEP branch in both the | |
| 52 | - | Rust and the browser halves); drop a repo's secrets when repo delete lands | |
| 46 | + | Rust and the browser halves) | |
modifiedcrates/anvil-core/src/agent.rs+27 −0
| ⋯ 104 unchanged lines | |||
| 105 | 105 | Ok(sessions.into_iter().take(limit).collect()) | |
| 106 | 106 | } | |
| 107 | 107 | ||
| 108 | + | /// Delete every session row for a repository, returning the deleted ids so the | |
| 109 | + | /// caller can remove their transcripts. Refuses nothing: callers check for live | |
| 110 | + | /// sessions first — see [`repos::delete`](crate::repos::delete), which will not | |
| 111 | + | /// delete a repository whose containers are still up. | |
| 112 | + | pub async fn delete_for_repo(db: &toasty::Db, repo_id: i64) -> Result<Vec<i64>> { | |
| 113 | + | let mut conn = db.clone(); | |
| 114 | + | let sessions = AgentSession::filter(AgentSession::fields().repo_id().eq(repo_id)) | |
| 115 | + | .exec(&mut conn) | |
| 116 | + | .await?; | |
| 117 | + | let mut ids = Vec::with_capacity(sessions.len()); | |
| 118 | + | for session in sessions { | |
| 119 | + | ids.push(session.id); | |
| 120 | + | let mut conn = db.clone(); | |
| 121 | + | session.delete().exec(&mut conn).await?; | |
| 122 | + | } | |
| 123 | + | Ok(ids) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | /// A repository's sessions that still believe they have a container. | |
| 127 | + | pub async fn live_for_repo(db: &toasty::Db, repo_id: i64) -> Result<Vec<AgentSession>> { | |
| 128 | + | Ok(live(db) | |
| 129 | + | .await? | |
| 130 | + | .into_iter() | |
| 131 | + | .filter(|s| s.repo_id == repo_id) | |
| 132 | + | .collect()) | |
| 133 | + | } | |
| 134 | + | ||
| 108 | 135 | /// Every session that believes it still has a container — used by the sweep | |
| 109 | 136 | /// and by the startup reconcile. | |
| 110 | 137 | pub async fn live(db: &toasty::Db) -> Result<Vec<AgentSession>> { | |
| ⋯ 182 unchanged lines | |||
modifiedcrates/anvil-core/src/attachments.rs+15 −0
| ⋯ 33 unchanged lines | |||
| 34 | 34 | Ok(row) | |
| 35 | 35 | } | |
| 36 | 36 | ||
| 37 | + | /// Delete every attachment row for a repository. The bytes under | |
| 38 | + | /// [`crate::storage::attachment_path`] are the caller's to remove — see | |
| 39 | + | /// [`repos::delete`](crate::repos::delete). | |
| 40 | + | pub async fn delete_for_repo(db: &toasty::Db, repo_id: i64) -> Result<()> { | |
| 41 | + | let mut conn = db.clone(); | |
| 42 | + | let rows = Attachment::filter(Attachment::fields().repo_id().eq(repo_id)) | |
| 43 | + | .exec(&mut conn) | |
| 44 | + | .await?; | |
| 45 | + | for row in rows { | |
| 46 | + | let mut conn = db.clone(); | |
| 47 | + | row.delete().exec(&mut conn).await?; | |
| 48 | + | } | |
| 49 | + | Ok(()) | |
| 50 | + | } | |
| 51 | + | ||
| 37 | 52 | /// Record an attachment for a repo, deduping on content: if `hash` is already | |
| 38 | 53 | /// recorded for `repo_id` the existing row is returned and no new row is made. | |
| 39 | 54 | /// The caller writes the bytes to [`crate::storage::attachment_path`] itself. | |
| ⋯ 40 unchanged lines | |||
modifiedcrates/anvil-core/src/ci.rs+22 −0
| ⋯ 366 unchanged lines | |||
| 367 | 367 | Ok(()) | |
| 368 | 368 | } | |
| 369 | 369 | ||
| 370 | + | /// Delete every run and artifact row belonging to a repository, returning the | |
| 371 | + | /// run ids that were removed so the caller can release their leases. On-disk | |
| 372 | + | /// artifact directories are the caller's to remove — see | |
| 373 | + | /// [`repos::delete`](crate::repos::delete), the only user. | |
| 374 | + | pub async fn delete_for_repo(db: &toasty::Db, repo_id: i64) -> Result<Vec<i64>> { | |
| 375 | + | for artifact in artifacts_for_repo(db, repo_id).await? { | |
| 376 | + | let mut conn = db.clone(); | |
| 377 | + | artifact.delete().exec(&mut conn).await?; | |
| 378 | + | } | |
| 379 | + | let mut conn = db.clone(); | |
| 380 | + | let runs = CiRun::filter(CiRun::fields().repo_id().eq(repo_id)) | |
| 381 | + | .exec(&mut conn) | |
| 382 | + | .await?; | |
| 383 | + | let mut ids = Vec::with_capacity(runs.len()); | |
| 384 | + | for run in runs { | |
| 385 | + | ids.push(run.id); | |
| 386 | + | let mut conn = db.clone(); | |
| 387 | + | run.delete().exec(&mut conn).await?; | |
| 388 | + | } | |
| 389 | + | Ok(ids) | |
| 390 | + | } | |
| 391 | + | ||
| 370 | 392 | #[cfg(test)] | |
| 371 | 393 | mod tests { | |
| 372 | 394 | use super::*; | |
| ⋯ 155 unchanged lines | |||
modifiedcrates/anvil-core/src/issues.rs+18 −0
| ⋯ 129 unchanged lines | |||
| 130 | 130 | Ok(comment) | |
| 131 | 131 | } | |
| 132 | 132 | ||
| 133 | + | /// Delete every issue on a repository, comments included. Used by | |
| 134 | + | /// [`repos::delete`](crate::repos::delete). | |
| 135 | + | pub async fn delete_for_repo(db: &toasty::Db, repo_id: i64) -> Result<()> { | |
| 136 | + | let mut conn = db.clone(); | |
| 137 | + | let issues = Issue::filter(Issue::fields().repo_id().eq(repo_id)) | |
| 138 | + | .exec(&mut conn) | |
| 139 | + | .await?; | |
| 140 | + | for issue in issues { | |
| 141 | + | for comment in comments(db, issue.id).await? { | |
| 142 | + | let mut conn = db.clone(); | |
| 143 | + | comment.delete().exec(&mut conn).await?; | |
| 144 | + | } | |
| 145 | + | let mut conn = db.clone(); | |
| 146 | + | issue.delete().exec(&mut conn).await?; | |
| 147 | + | } | |
| 148 | + | Ok(()) | |
| 149 | + | } | |
| 150 | + | ||
| 133 | 151 | /// An issue's comments, oldest first. | |
| 134 | 152 | pub async fn comments(db: &toasty::Db, issue_id: i64) -> Result<Vec<IssueComment>> { | |
| 135 | 153 | let mut conn = db.clone(); | |
| ⋯ 46 unchanged lines | |||
modifiedcrates/anvil-core/src/repos.rs+250 −0
| ⋯ 100 unchanged lines | |||
| 101 | 101 | Ok(Some(repo)) | |
| 102 | 102 | } | |
| 103 | 103 | ||
| 104 | + | /// Delete a repository: every row that hangs off it, then its bytes on disk. | |
| 105 | + | /// | |
| 106 | + | /// This is the one destructive operation in the forge, so the order is chosen | |
| 107 | + | /// for what a crash halfway through leaves behind. The bare repository is moved | |
| 108 | + | /// aside first (see [`storage::stage_removal`]), the rows go next, and the | |
| 109 | + | /// actual `remove_dir_all` calls come last and only log on failure. Any | |
| 110 | + | /// interruption therefore leaves unreferenced bytes rather than rows pointing | |
| 111 | + | /// at a repository that isn't there. | |
| 112 | + | /// | |
| 113 | + | /// Refuses while the repository has a live agent session: those containers have | |
| 114 | + | /// a workspace seeded from the repository, and pulling the directory out from | |
| 115 | + | /// under a running supervisor is a worse failure than asking the owner to stop | |
| 116 | + | /// it. In-flight CI is not a blocker — a runner reporting a result for a run | |
| 117 | + | /// that no longer exists is a no-op by construction ([`ci::finish`] and friends | |
| 118 | + | /// return early on a missing row) — but the run's lease is released so the | |
| 119 | + | /// dispatcher stops accounting for it. | |
| 120 | + | pub async fn delete(app: &crate::App, owner: &User, repo: &Repository) -> Result<()> { | |
| 121 | + | let live = crate::agent::live_for_repo(&app.db, repo.id).await?; | |
| 122 | + | if !live.is_empty() { | |
| 123 | + | let ids: Vec<String> = live.iter().map(|s| format!("#{}", s.id)).collect(); | |
| 124 | + | return Err(Error::Invalid(format!( | |
| 125 | + | "{}/{} has {} running agent {} ({}) — stop {} first", | |
| 126 | + | owner.username, | |
| 127 | + | repo.name, | |
| 128 | + | live.len(), | |
| 129 | + | if live.len() == 1 { | |
| 130 | + | "session" | |
| 131 | + | } else { | |
| 132 | + | "sessions" | |
| 133 | + | }, | |
| 134 | + | ids.join(", "), | |
| 135 | + | if live.len() == 1 { "it" } else { "them" }, | |
| 136 | + | ))); | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | let staged = storage::stage_removal( | |
| 140 | + | &app.config.repositories_dir(), | |
| 141 | + | &owner.username, | |
| 142 | + | &repo.name, | |
| 143 | + | repo.id, | |
| 144 | + | )?; | |
| 145 | + | ||
| 146 | + | for run_id in crate::ci::delete_for_repo(&app.db, repo.id).await? { | |
| 147 | + | app.jobs.release(run_id); | |
| 148 | + | } | |
| 149 | + | crate::issues::delete_for_repo(&app.db, repo.id).await?; | |
| 150 | + | crate::attachments::delete_for_repo(&app.db, repo.id).await?; | |
| 151 | + | crate::secrets::delete_all(&app.db, repo.id).await?; | |
| 152 | + | app.vault.lock(repo.id); | |
| 153 | + | let sessions = crate::agent::delete_for_repo(&app.db, repo.id).await?; | |
| 154 | + | ||
| 155 | + | let mut conn = app.db.clone(); | |
| 156 | + | let Some(row) = Repository::filter(Repository::fields().id().eq(repo.id)) | |
| 157 | + | .first() | |
| 158 | + | .exec(&mut conn) | |
| 159 | + | .await? | |
| 160 | + | else { | |
| 161 | + | return Err(Error::NotFound(format!("repository id {}", repo.id))); | |
| 162 | + | }; | |
| 163 | + | let mut conn = app.db.clone(); | |
| 164 | + | row.delete().exec(&mut conn).await?; | |
| 165 | + | ||
| 166 | + | if let Some(staged) = staged { | |
| 167 | + | storage::remove_tree(&staged); | |
| 168 | + | } | |
| 169 | + | storage::remove_tree(&app.config.artifacts_dir().join(repo.id.to_string())); | |
| 170 | + | storage::remove_tree(&app.config.attachments_dir().join(repo.id.to_string())); | |
| 171 | + | let sessions_dir = app.config.sessions_dir(); | |
| 172 | + | for id in sessions { | |
| 173 | + | let transcript = storage::session_transcript_path(&sessions_dir, id); | |
| 174 | + | if transcript.exists() | |
| 175 | + | && let Err(e) = std::fs::remove_file(&transcript) | |
| 176 | + | { | |
| 177 | + | tracing::warn!("could not remove {}: {e}", transcript.display()); | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | tracing::info!("deleted repository {}/{}", owner.username, repo.name); | |
| 182 | + | Ok(()) | |
| 183 | + | } | |
| 184 | + | ||
| 104 | 185 | /// Find a repository by its id. | |
| 105 | 186 | pub async fn find_by_id(db: &toasty::Db, id: i64) -> Result<Option<Repository>> { | |
| 106 | 187 | let mut db = db.clone(); | |
| ⋯ 184 unchanged lines | |||
| 291 | 372 | .is_err() | |
| 292 | 373 | ); | |
| 293 | 374 | } | |
| 375 | + | ||
| 376 | + | /// An app on a temp data dir, plus one user. | |
| 377 | + | async fn app_with_user(dir: &Path) -> (crate::App, User) { | |
| 378 | + | let config = crate::Config { | |
| 379 | + | data_dir: dir.to_path_buf(), | |
| 380 | + | ..Default::default() | |
| 381 | + | }; | |
| 382 | + | let app = crate::App::bootstrap(config).await.unwrap(); | |
| 383 | + | let user = crate::users::create(&app.db, "alice", "", "pw-alice-1", false) | |
| 384 | + | .await | |
| 385 | + | .unwrap(); | |
| 386 | + | (app, user) | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | /// Deleting a repository takes every row that hangs off it and its bytes on | |
| 390 | + | /// disk, and frees the name for re-use. | |
| 391 | + | #[tokio::test] | |
| 392 | + | async fn delete_cascades_and_frees_the_name() { | |
| 393 | + | let dir = tempfile::tempdir().unwrap(); | |
| 394 | + | let (app, alice) = app_with_user(dir.path()).await; | |
| 395 | + | let repos_dir = app.config.repositories_dir(); | |
| 396 | + | let repo = create(&app.db, &repos_dir, &alice, "proj", "a repo", false) | |
| 397 | + | .await | |
| 398 | + | .unwrap(); | |
| 399 | + | let other = create(&app.db, &repos_dir, &alice, "keep", "", false) | |
| 400 | + | .await | |
| 401 | + | .unwrap(); | |
| 402 | + | ||
| 403 | + | // Hang one of everything off the repository, on disk as well as in the | |
| 404 | + | // database, so the cascade has something to miss. | |
| 405 | + | let run = crate::ci::enqueue(&app.db, repo.id, "c0ffee", "main") | |
| 406 | + | .await | |
| 407 | + | .unwrap(); | |
| 408 | + | crate::ci::add_artifact( | |
| 409 | + | &app.db, run.id, repo.id, "c0ffee", "site", 3, true, true, "{}", | |
| 410 | + | ) | |
| 411 | + | .await | |
| 412 | + | .unwrap(); | |
| 413 | + | let mut issue = crate::issues::create(&app.db, repo.id, alice.id, "a bug", "") | |
| 414 | + | .await | |
| 415 | + | .unwrap(); | |
| 416 | + | crate::issues::comment(&app.db, &mut issue, alice.id, "me too") | |
| 417 | + | .await | |
| 418 | + | .unwrap(); | |
| 419 | + | crate::attachments::add(&app.db, repo.id, "abc123", "image/png", 3, alice.id) | |
| 420 | + | .await | |
| 421 | + | .unwrap(); | |
| 422 | + | let session = crate::agent::create( | |
| 423 | + | &app.db, | |
| 424 | + | repo.id, | |
| 425 | + | alice.id, | |
| 426 | + | crate::agent::kind::INTERACTIVE, | |
| 427 | + | "main", | |
| 428 | + | "c0ffee", | |
| 429 | + | "", | |
| 430 | + | "img", | |
| 431 | + | "", | |
| 432 | + | ) | |
| 433 | + | .await | |
| 434 | + | .unwrap(); | |
| 435 | + | crate::agent::finish(&app.db, session.id, crate::agent::status::EXITED, 0, "") | |
| 436 | + | .await | |
| 437 | + | .unwrap(); | |
| 438 | + | ||
| 439 | + | let artifact_dir = storage::artifact_commit_dir(&app.config.artifacts_dir(), repo.id, "c0"); | |
| 440 | + | std::fs::create_dir_all(&artifact_dir).unwrap(); | |
| 441 | + | std::fs::write(artifact_dir.join("index.html"), "hi").unwrap(); | |
| 442 | + | let attachment = storage::attachment_path(&app.config.attachments_dir(), repo.id, "abc123"); | |
| 443 | + | std::fs::create_dir_all(attachment.parent().unwrap()).unwrap(); | |
| 444 | + | std::fs::write(&attachment, "png").unwrap(); | |
| 445 | + | let transcript = storage::session_transcript_path(&app.config.sessions_dir(), session.id); | |
| 446 | + | std::fs::create_dir_all(app.config.sessions_dir()).unwrap(); | |
| 447 | + | std::fs::write(&transcript, "$ ").unwrap(); | |
| 448 | + | ||
| 449 | + | delete(&app, &alice, &repo).await.unwrap(); | |
| 450 | + | ||
| 451 | + | assert!(find(&app.db, alice.id, "proj").await.unwrap().is_none()); | |
| 452 | + | assert!(!storage::repo_path(&repos_dir, "alice", "proj").exists()); | |
| 453 | + | assert!( | |
| 454 | + | crate::ci::list_by_repo(&app.db, repo.id, 10) | |
| 455 | + | .await | |
| 456 | + | .unwrap() | |
| 457 | + | .is_empty() | |
| 458 | + | ); | |
| 459 | + | assert!( | |
| 460 | + | crate::ci::artifacts_for_repo(&app.db, repo.id) | |
| 461 | + | .await | |
| 462 | + | .unwrap() | |
| 463 | + | .is_empty() | |
| 464 | + | ); | |
| 465 | + | assert!( | |
| 466 | + | crate::issues::list(&app.db, repo.id, crate::issues::state::OPEN) | |
| 467 | + | .await | |
| 468 | + | .unwrap() | |
| 469 | + | .is_empty() | |
| 470 | + | ); | |
| 471 | + | assert!( | |
| 472 | + | crate::issues::comments(&app.db, issue.id) | |
| 473 | + | .await | |
| 474 | + | .unwrap() | |
| 475 | + | .is_empty() | |
| 476 | + | ); | |
| 477 | + | assert!( | |
| 478 | + | crate::attachments::find(&app.db, repo.id, "abc123") | |
| 479 | + | .await | |
| 480 | + | .unwrap() | |
| 481 | + | .is_none() | |
| 482 | + | ); | |
| 483 | + | assert!( | |
| 484 | + | crate::agent::list_by_repo(&app.db, repo.id, 10) | |
| 485 | + | .await | |
| 486 | + | .unwrap() | |
| 487 | + | .is_empty() | |
| 488 | + | ); | |
| 489 | + | assert!(!artifact_dir.exists()); | |
| 490 | + | assert!(!attachment.exists()); | |
| 491 | + | assert!(!transcript.exists()); | |
| 492 | + | ||
| 493 | + | // The neighbouring repository is untouched, and the freed name can be | |
| 494 | + | // used again — the on-disk directory really is gone, not just orphaned. | |
| 495 | + | assert!(find(&app.db, alice.id, "keep").await.unwrap().is_some()); | |
| 496 | + | assert!(storage::repo_path(&repos_dir, "alice", &other.name).exists()); | |
| 497 | + | create(&app.db, &repos_dir, &alice, "proj", "", true) | |
| 498 | + | .await | |
| 499 | + | .unwrap(); | |
| 500 | + | } | |
| 501 | + | ||
| 502 | + | /// A repository with a session that still claims a container is not | |
| 503 | + | /// deletable: the containers would outlive their workspace. | |
| 504 | + | #[tokio::test] | |
| 505 | + | async fn delete_refuses_while_an_agent_session_is_live() { | |
| 506 | + | let dir = tempfile::tempdir().unwrap(); | |
| 507 | + | let (app, alice) = app_with_user(dir.path()).await; | |
| 508 | + | let repo = create( | |
| 509 | + | &app.db, | |
| 510 | + | &app.config.repositories_dir(), | |
| 511 | + | &alice, | |
| 512 | + | "proj", | |
| 513 | + | "", | |
| 514 | + | false, | |
| 515 | + | ) | |
| 516 | + | .await | |
| 517 | + | .unwrap(); | |
| 518 | + | let session = crate::agent::create( | |
| 519 | + | &app.db, | |
| 520 | + | repo.id, | |
| 521 | + | alice.id, | |
| 522 | + | crate::agent::kind::INTERACTIVE, | |
| 523 | + | "main", | |
| 524 | + | "c0ffee", | |
| 525 | + | "", | |
| 526 | + | "img", | |
| 527 | + | "", | |
| 528 | + | ) | |
| 529 | + | .await | |
| 530 | + | .unwrap(); | |
| 531 | + | ||
| 532 | + | let err = delete(&app, &alice, &repo).await.unwrap_err().to_string(); | |
| 533 | + | assert!(err.contains(&format!("#{}", session.id)), "{err}"); | |
| 534 | + | assert!(find(&app.db, alice.id, "proj").await.unwrap().is_some()); | |
| 535 | + | assert!(storage::repo_path(&app.config.repositories_dir(), "alice", "proj").exists()); | |
| 536 | + | ||
| 537 | + | // Once it is closed out, the delete goes through. | |
| 538 | + | crate::agent::finish(&app.db, session.id, crate::agent::status::REAPED, 0, "") | |
| 539 | + | .await | |
| 540 | + | .unwrap(); | |
| 541 | + | delete(&app, &alice, &repo).await.unwrap(); | |
| 542 | + | assert!(find(&app.db, alice.id, "proj").await.unwrap().is_none()); | |
| 543 | + | } | |
| 294 | 544 | } | |
modifiedcrates/anvil-core/src/storage.rs+45 −0
| ⋯ 70 unchanged lines | |||
| 71 | 71 | Ok(repo) | |
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | + | /// Move a bare repository out of the way, returning the path it now sits at | |
| 75 | + | /// (`None` if there was nothing on disk). | |
| 76 | + | /// | |
| 77 | + | /// Deletion moves before it removes so the two halves — the database row and | |
| 78 | + | /// the directory — can't disagree in the direction that hurts. A rename is | |
| 79 | + | /// atomic and cheap; once it succeeds the name is free for re-creation, and a | |
| 80 | + | /// crash before the removal leaves only unreferenced bytes, which an operator | |
| 81 | + | /// can delete at leisure. Removing first would risk the opposite: a live row | |
| 82 | + | /// pointing at a repository that no longer exists. | |
| 83 | + | /// | |
| 84 | + | /// `repo_id` names the staging directory, so two deletions can't collide (ids | |
| 85 | + | /// are never reused). | |
| 86 | + | pub fn stage_removal( | |
| 87 | + | repositories_dir: &Path, | |
| 88 | + | owner: &str, | |
| 89 | + | name: &str, | |
| 90 | + | repo_id: i64, | |
| 91 | + | ) -> Result<Option<PathBuf>> { | |
| 92 | + | let path = repo_path(repositories_dir, owner, name); | |
| 93 | + | if !path.exists() { | |
| 94 | + | return Ok(None); | |
| 95 | + | } | |
| 96 | + | let staged = path.with_file_name(format!("{name}.git.deleted-{repo_id}")); | |
| 97 | + | std::fs::rename(&path, &staged).map_err(|e| { | |
| 98 | + | Error::Storage(format!( | |
| 99 | + | "move {} aside to {}: {e}", | |
| 100 | + | path.display(), | |
| 101 | + | staged.display() | |
| 102 | + | )) | |
| 103 | + | })?; | |
| 104 | + | Ok(Some(staged)) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | /// Remove a directory tree, logging rather than failing: every caller is past | |
| 108 | + | /// the point where the forge has already forgotten what the bytes were, so a | |
| 109 | + | /// leftover directory is an operator cleanup, not an error to report. | |
| 110 | + | pub fn remove_tree(path: &Path) { | |
| 111 | + | if !path.exists() { | |
| 112 | + | return; | |
| 113 | + | } | |
| 114 | + | if let Err(e) = std::fs::remove_dir_all(path) { | |
| 115 | + | tracing::warn!("could not remove {}: {e}", path.display()); | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 74 | 119 | /// Point `HEAD` at `refs/heads/<branch>` as a symbolic reference. | |
| 75 | 120 | fn set_head_branch(repo: &gix::Repository, branch: &str) -> Result<()> { | |
| 76 | 121 | use gix::refs::{ | |
| ⋯ 55 unchanged lines | |||
modifiedcrates/anvil-web/src/ui.rs+114 −5
| ⋯ 193 unchanged lines | |||
| 194 | 194 | .issue-post { margin:12px 0; } | |
| 195 | 195 | .issue-head { padding:8px 16px; border-bottom:1px solid var(--border); background:var(--code-bg); font-size:13px; color:var(--muted); } | |
| 196 | 196 | .btn.btn-secondary { background:var(--bg); color:var(--fg); border-color:var(--border); } | |
| 197 | + | .btn.btn-danger { background:var(--error); border-color:var(--error); } | |
| 198 | + | .btn:disabled { opacity:.5; cursor:not-allowed; } | |
| 199 | + | .danger { border:1px solid var(--error); border-radius:6px; padding:4px 16px 12px; } | |
| 197 | 200 | .readme { margin-top:16px; } | |
| 198 | 201 | .readme-head { padding:8px 16px; border-bottom:1px solid var(--border); background:var(--code-bg); font-size:13px; font-weight:600; } | |
| 199 | 202 | /* Todo board: a ledger, not a card wall. Each column is a hairline rail with | |
| ⋯ 222 unchanged lines | |||
| 422 | 425 | "/{owner}/{repo}/settings", | |
| 423 | 426 | get(repo_settings).post(repo_settings_submit), | |
| 424 | 427 | ) | |
| 428 | + | .route("/{owner}/{repo}/settings/delete", post(repo_delete)) | |
| 425 | 429 | .route("/{owner}/{repo}", get(repo_index)) | |
| 426 | 430 | .route("/{owner}/{repo}/tree/{rev}", get(tree_root)) | |
| 427 | 431 | .route("/{owner}/{repo}/tree/{rev}/{*path}", get(tree_path)) | |
| ⋯ 525 unchanged lines | |||
| 953 | 957 | csrf: String, | |
| 954 | 958 | } | |
| 955 | 959 | ||
| 960 | + | #[derive(serde::Deserialize)] | |
| 961 | + | struct DeleteRepoForm { | |
| 962 | + | /// The repository name, retyped by hand. Anything else is a refusal. | |
| 963 | + | #[serde(default)] | |
| 964 | + | confirm: String, | |
| 965 | + | #[serde(default)] | |
| 966 | + | csrf: String, | |
| 967 | + | } | |
| 968 | + | ||
| 956 | 969 | /// `GET /new` — new-repository form (requires login). | |
| 957 | 970 | async fn new_repo_form( | |
| 958 | 971 | State(app): State<App>, | |
| ⋯ 94 unchanged lines | |||
| 1053 | 1066 | ) | |
| 1054 | 1067 | } | |
| 1055 | 1068 | ||
| 1056 | - | /// Load a repo for an owner-only settings action, enforcing write access. | |
| 1069 | + | /// Load a repo and its owner for an owner-only settings action, enforcing | |
| 1070 | + | /// write access. | |
| 1057 | 1071 | async fn resolve_for_settings( | |
| 1058 | 1072 | app: &App, | |
| 1059 | 1073 | viewer: Option<&User>, | |
| 1060 | 1074 | owner: &str, | |
| 1061 | 1075 | name: &str, | |
| 1062 | - | ) -> Result<Repository, Response> { | |
| 1076 | + | ) -> Result<(User, Repository), Response> { | |
| 1063 | 1077 | let owner_user = users::find_by_username(&app.db, owner) | |
| 1064 | 1078 | .await | |
| 1065 | 1079 | .map_err(server_error)? | |
| ⋯ 8 unchanged lines | |||
| 1074 | 1088 | if !access::can_write(&repo, viewer) { | |
| 1075 | 1089 | return Err(forbidden()); | |
| 1076 | 1090 | } | |
| 1077 | - | Ok(repo) | |
| 1091 | + | Ok((owner_user, repo)) | |
| 1078 | 1092 | } | |
| 1079 | 1093 | ||
| 1080 | 1094 | /// `GET /{owner}/{repo}/settings` — owner-only repository settings. | |
| ⋯ 3 unchanged lines | |||
| 1084 | 1098 | csrf: Csrf, | |
| 1085 | 1099 | Path((owner, repo)): Path<(String, String)>, | |
| 1086 | 1100 | ) -> Response { | |
| 1087 | - | let meta = match resolve_for_settings(&app, user.as_ref(), &owner, &repo).await { | |
| 1101 | + | let (_, meta) = match resolve_for_settings(&app, user.as_ref(), &owner, &repo).await { | |
| 1088 | 1102 | Ok(m) => m, | |
| 1089 | 1103 | Err(resp) => return resp, | |
| 1090 | 1104 | }; | |
| ⋯ 9 unchanged lines | |||
| 1100 | 1114 | Path((owner, repo)): Path<(String, String)>, | |
| 1101 | 1115 | Form(form): Form<SettingsForm>, | |
| 1102 | 1116 | ) -> Response { | |
| 1103 | - | let meta = match resolve_for_settings(&app, user.as_ref(), &owner, &repo).await { | |
| 1117 | + | let (_, meta) = match resolve_for_settings(&app, user.as_ref(), &owner, &repo).await { | |
| 1104 | 1118 | Ok(m) => m, | |
| 1105 | 1119 | Err(resp) => return resp, | |
| 1106 | 1120 | }; | |
| ⋯ 14 unchanged lines | |||
| 1121 | 1135 | Redirect::to(&format!("/{owner}/{repo}")).into_response() | |
| 1122 | 1136 | } | |
| 1123 | 1137 | ||
| 1138 | + | /// `POST /{owner}/{repo}/settings/delete` — delete the repository for good. | |
| 1139 | + | /// | |
| 1140 | + | /// The typed-name confirmation is checked here, not only in the browser: the | |
| 1141 | + | /// point of it is that no single stray click can destroy a repository, and a | |
| 1142 | + | /// check that lives in JavaScript is not a check at all for anything posting | |
| 1143 | + | /// the form directly. | |
| 1144 | + | async fn repo_delete( | |
| 1145 | + | State(app): State<App>, | |
| 1146 | + | CurrentUser(user): CurrentUser, | |
| 1147 | + | csrf: Csrf, | |
| 1148 | + | Path((owner, repo)): Path<(String, String)>, | |
| 1149 | + | Form(form): Form<DeleteRepoForm>, | |
| 1150 | + | ) -> Response { | |
| 1151 | + | let (owner_user, meta) = match resolve_for_settings(&app, user.as_ref(), &owner, &repo).await { | |
| 1152 | + | Ok(m) => m, | |
| 1153 | + | Err(resp) => return resp, | |
| 1154 | + | }; | |
| 1155 | + | if let Err(resp) = verify_csrf(&csrf, &form.csrf) { | |
| 1156 | + | return resp; | |
| 1157 | + | } | |
| 1158 | + | ||
| 1159 | + | let error = if form.confirm.trim() != meta.name { | |
| 1160 | + | Some(format!( | |
| 1161 | + | "Type {} exactly to confirm — the repository was not deleted.", | |
| 1162 | + | meta.name | |
| 1163 | + | )) | |
| 1164 | + | } else { | |
| 1165 | + | match repos::delete(&app, &owner_user, &meta).await { | |
| 1166 | + | Ok(()) => return Redirect::to(&format!("/{owner}")).into_response(), | |
| 1167 | + | // A live agent session is the one refusal the owner can act on, so | |
| 1168 | + | // it is shown on the page rather than as a 500. | |
| 1169 | + | Err(anvil_core::Error::Invalid(msg)) => Some(msg), | |
| 1170 | + | Err(e) => return server_error(e), | |
| 1171 | + | } | |
| 1172 | + | }; | |
| 1173 | + | ||
| 1174 | + | let secrets = crate::secrets::settings_section(&app, &owner, &repo, &meta).await; | |
| 1175 | + | settings_page( | |
| 1176 | + | user.as_ref(), | |
| 1177 | + | &owner, | |
| 1178 | + | &repo, | |
| 1179 | + | &meta, | |
| 1180 | + | secrets, | |
| 1181 | + | error.as_deref(), | |
| 1182 | + | &csrf.0, | |
| 1183 | + | ) | |
| 1184 | + | .into_response() | |
| 1185 | + | } | |
| 1186 | + | ||
| 1124 | 1187 | fn settings_page( | |
| 1125 | 1188 | user: Option<&User>, | |
| 1126 | 1189 | owner: &str, | |
| ⋯ 29 unchanged lines | |||
| 1156 | 1219 | p { button.btn type="submit" { "Save changes" } } | |
| 1157 | 1220 | } | |
| 1158 | 1221 | (secrets) | |
| 1222 | + | (delete_section(owner, repo, meta, csrf)) | |
| 1159 | 1223 | }, | |
| 1160 | 1224 | ) | |
| 1161 | 1225 | } | |
| 1162 | 1226 | ||
| 1227 | + | /// The delete-repository box: what goes, and the typed-name confirmation that | |
| 1228 | + | /// gates it. The script below disables the button until the field matches; | |
| 1229 | + | /// with JavaScript off the button stays live and the server makes the same | |
| 1230 | + | /// comparison, so the form still works and still cannot be fired blind. | |
| 1231 | + | fn delete_section(owner: &str, repo: &str, meta: &Repository, csrf: &str) -> Markup { | |
| 1232 | + | html! { | |
| 1233 | + | h2 { "Delete this repository" } | |
| 1234 | + | div.danger { | |
| 1235 | + | p.muted { | |
| 1236 | + | "Deletes the repository and everything anvil keeps alongside it: " | |
| 1237 | + | "commits and branches, CI runs and their artifacts, issues, " | |
| 1238 | + | "uploaded attachments, agent session transcripts, and stored " | |
| 1239 | + | "secrets. Clones elsewhere are unaffected. " | |
| 1240 | + | b { "This cannot be undone." } | |
| 1241 | + | } | |
| 1242 | + | form.stack.delete-repo method="post" action=(format!("/{owner}/{repo}/settings/delete")) { | |
| 1243 | + | (csrf_input(csrf)) | |
| 1244 | + | p { | |
| 1245 | + | label { | |
| 1246 | + | "Type " code { (meta.name) } " to confirm" br; | |
| 1247 | + | input type="text" name="confirm" autocomplete="off" | |
| 1248 | + | data-expect=(meta.name) required; | |
| 1249 | + | } | |
| 1250 | + | } | |
| 1251 | + | p { button.btn.btn-danger type="submit" { "Delete this repository" } } | |
| 1252 | + | } | |
| 1253 | + | } | |
| 1254 | + | script { (PreEscaped(DELETE_CONFIRM_JS)) } | |
| 1255 | + | } | |
| 1256 | + | } | |
| 1257 | + | ||
| 1258 | + | /// Enables the delete button only once the typed name matches. Progressive | |
| 1259 | + | /// enhancement over the server-side check — see [`repo_delete`]. | |
| 1260 | + | const DELETE_CONFIRM_JS: &str = r#" | |
| 1261 | + | (function () { | |
| 1262 | + | var form = document.querySelector('form.delete-repo'); | |
| 1263 | + | if (!form) return; | |
| 1264 | + | var input = form.querySelector('input[name=confirm]'); | |
| 1265 | + | var button = form.querySelector('button[type=submit]'); | |
| 1266 | + | var sync = function () { button.disabled = input.value.trim() !== input.dataset.expect; }; | |
| 1267 | + | input.addEventListener('input', sync); | |
| 1268 | + | sync(); | |
| 1269 | + | })(); | |
| 1270 | + | "#; | |
| 1271 | + | ||
| 1163 | 1272 | fn clone_box(app: &App, owner: &str, name: &str) -> Markup { | |
| 1164 | 1273 | let http = app.config.http_clone_url(owner, name); | |
| 1165 | 1274 | let ssh = app | |
| ⋯ 1722 unchanged lines | |||
addedcrates/anvil-web/tests/repo_delete.rs+236 −0
| 1 | + | //! Deleting a repository over HTTP: who may, and what has to be typed first. | |
| 2 | + | //! | |
| 3 | + | //! The cascade itself is tested in `anvil_core::repos`. What only exists at | |
| 4 | + | //! this layer is the guard rail — a delete needs a session, write access, a | |
| 5 | + | //! CSRF token, *and* the repository's name retyped — and the guard rail is the | |
| 6 | + | //! whole point of the feature, so it is checked against the real router rather | |
| 7 | + | //! than by reading the handler. | |
| 8 | + | ||
| 9 | + | use anvil_core::{ | |
| 10 | + | App, | |
| 11 | + | Config, | |
| 12 | + | repos, | |
| 13 | + | sessions, | |
| 14 | + | storage, | |
| 15 | + | users, | |
| 16 | + | }; | |
| 17 | + | use axum::{ | |
| 18 | + | Router, | |
| 19 | + | body::Body, | |
| 20 | + | http::{ | |
| 21 | + | Request, | |
| 22 | + | StatusCode, | |
| 23 | + | header, | |
| 24 | + | }, | |
| 25 | + | }; | |
| 26 | + | use tower::ServiceExt; | |
| 27 | + | ||
| 28 | + | struct Harness { | |
| 29 | + | app: App, | |
| 30 | + | router: Router, | |
| 31 | + | _dir: tempfile::TempDir, | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | async fn harness() -> Harness { | |
| 35 | + | let dir = tempfile::tempdir().unwrap(); | |
| 36 | + | let config = Config { | |
| 37 | + | data_dir: dir.path().to_path_buf(), | |
| 38 | + | ..Default::default() | |
| 39 | + | }; | |
| 40 | + | let app = App::bootstrap(config).await.unwrap(); | |
| 41 | + | Harness { | |
| 42 | + | router: anvil_web::router(app.clone()), | |
| 43 | + | app, | |
| 44 | + | _dir: dir, | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | impl Harness { | |
| 49 | + | /// Sign a user in, returning `(cookie header, csrf token)`. | |
| 50 | + | async fn sign_in(&self, user_id: i64) -> (String, String) { | |
| 51 | + | let session = sessions::create(&self.app.db, user_id).await.unwrap(); | |
| 52 | + | let csrf = self.app.csrf_token(&session.token); | |
| 53 | + | (format!("anvil_session={}", session.token), csrf) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /// The rendered body of a page, for asserting on markup. | |
| 57 | + | async fn get_body(&self, path: &str, cookie: &str) -> String { | |
| 58 | + | let response = self | |
| 59 | + | .router | |
| 60 | + | .clone() | |
| 61 | + | .oneshot( | |
| 62 | + | Request::get(path) | |
| 63 | + | .header(header::COOKIE, cookie) | |
| 64 | + | .body(Body::empty()) | |
| 65 | + | .unwrap(), | |
| 66 | + | ) | |
| 67 | + | .await | |
| 68 | + | .unwrap(); | |
| 69 | + | let bytes = axum::body::to_bytes(response.into_body(), 1 << 20) | |
| 70 | + | .await | |
| 71 | + | .unwrap(); | |
| 72 | + | String::from_utf8_lossy(&bytes).into_owned() | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | /// POST a form body, returning `(status, Location)`. | |
| 76 | + | async fn post(&self, path: &str, cookie: Option<&str>, body: String) -> (StatusCode, String) { | |
| 77 | + | let mut req = | |
| 78 | + | Request::post(path).header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); | |
| 79 | + | if let Some(cookie) = cookie { | |
| 80 | + | req = req.header(header::COOKIE, cookie); | |
| 81 | + | } | |
| 82 | + | let response = self | |
| 83 | + | .router | |
| 84 | + | .clone() | |
| 85 | + | .oneshot(req.body(Body::from(body)).unwrap()) | |
| 86 | + | .await | |
| 87 | + | .unwrap(); | |
| 88 | + | let status = response.status(); | |
| 89 | + | let location = response | |
| 90 | + | .headers() | |
| 91 | + | .get(header::LOCATION) | |
| 92 | + | .map(|l| l.to_str().unwrap().to_string()) | |
| 93 | + | .unwrap_or_default(); | |
| 94 | + | (status, location) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | /// Every way of getting the delete wrong leaves the repository standing, and | |
| 99 | + | /// only the fully-formed request takes it. | |
| 100 | + | #[tokio::test] | |
| 101 | + | async fn delete_needs_the_owner_a_csrf_token_and_the_typed_name() { | |
| 102 | + | let h = harness().await; | |
| 103 | + | let alice = users::create(&h.app.db, "alice", "", "pw-alice-1", false) | |
| 104 | + | .await | |
| 105 | + | .unwrap(); | |
| 106 | + | let bob = users::create(&h.app.db, "bob", "", "pw-bob-1", false) | |
| 107 | + | .await | |
| 108 | + | .unwrap(); | |
| 109 | + | let repos_dir = h.app.config.repositories_dir(); | |
| 110 | + | repos::create(&h.app.db, &repos_dir, &alice, "proj", "", false) | |
| 111 | + | .await | |
| 112 | + | .unwrap(); | |
| 113 | + | ||
| 114 | + | let (alice_cookie, csrf) = h.sign_in(alice.id).await; | |
| 115 | + | let (bob_cookie, bob_csrf) = h.sign_in(bob.id).await; | |
| 116 | + | let path = "/alice/proj/settings/delete"; | |
| 117 | + | let still_there = || async { | |
| 118 | + | assert!( | |
| 119 | + | repos::find(&h.app.db, alice.id, "proj") | |
| 120 | + | .await | |
| 121 | + | .unwrap() | |
| 122 | + | .is_some(), | |
| 123 | + | "the repository should have survived" | |
| 124 | + | ); | |
| 125 | + | }; | |
| 126 | + | ||
| 127 | + | // The settings page is where the action lives, and it names the repository | |
| 128 | + | // the confirmation field expects. | |
| 129 | + | let page = h.get_body("/alice/proj/settings", &alice_cookie).await; | |
| 130 | + | assert!(page.contains(&format!("action=\"{path}\"")), "{page}"); | |
| 131 | + | assert!(page.contains("data-expect=\"proj\""), "{page}"); | |
| 132 | + | ||
| 133 | + | // Signed out: no session, no delete (a redirect to the login page). | |
| 134 | + | let (status, _) = h | |
| 135 | + | .post(path, None, format!("confirm=proj&csrf={csrf}")) | |
| 136 | + | .await; | |
| 137 | + | assert_ne!(status, StatusCode::SEE_OTHER); | |
| 138 | + | still_there().await; | |
| 139 | + | ||
| 140 | + | // Someone else's account, holding their own valid CSRF token: it is a | |
| 141 | + | // public repository, so the answer is forbidden rather than not-found. | |
| 142 | + | let (status, _) = h | |
| 143 | + | .post( | |
| 144 | + | path, | |
| 145 | + | Some(&bob_cookie), | |
| 146 | + | format!("confirm=proj&csrf={bob_csrf}"), | |
| 147 | + | ) | |
| 148 | + | .await; | |
| 149 | + | assert_eq!(status, StatusCode::FORBIDDEN); | |
| 150 | + | still_there().await; | |
| 151 | + | ||
| 152 | + | // The owner, but with no CSRF token — a cross-site POST cannot mint one. | |
| 153 | + | let (status, _) = h | |
| 154 | + | .post(path, Some(&alice_cookie), "confirm=proj".to_string()) | |
| 155 | + | .await; | |
| 156 | + | assert_eq!(status, StatusCode::FORBIDDEN); | |
| 157 | + | still_there().await; | |
| 158 | + | ||
| 159 | + | // The owner, with a token, but the name typed wrong: the page comes back | |
| 160 | + | // with an error instead of a redirect. | |
| 161 | + | let (status, location) = h | |
| 162 | + | .post( | |
| 163 | + | path, | |
| 164 | + | Some(&alice_cookie), | |
| 165 | + | format!("confirm=Proj&csrf={csrf}"), | |
| 166 | + | ) | |
| 167 | + | .await; | |
| 168 | + | assert_eq!( | |
| 169 | + | status, | |
| 170 | + | StatusCode::OK, | |
| 171 | + | "re-renders settings, not a redirect" | |
| 172 | + | ); | |
| 173 | + | assert!(location.is_empty()); | |
| 174 | + | still_there().await; | |
| 175 | + | ||
| 176 | + | // All four together, and it is gone — row and directory both. | |
| 177 | + | let (status, location) = h | |
| 178 | + | .post( | |
| 179 | + | path, | |
| 180 | + | Some(&alice_cookie), | |
| 181 | + | format!("confirm=proj&csrf={csrf}"), | |
| 182 | + | ) | |
| 183 | + | .await; | |
| 184 | + | assert_eq!(status, StatusCode::SEE_OTHER); | |
| 185 | + | assert_eq!(location, "/alice"); | |
| 186 | + | assert!( | |
| 187 | + | repos::find(&h.app.db, alice.id, "proj") | |
| 188 | + | .await | |
| 189 | + | .unwrap() | |
| 190 | + | .is_none() | |
| 191 | + | ); | |
| 192 | + | assert!(!storage::repo_path(&repos_dir, "alice", "proj").exists()); | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | /// An admin may delete someone else's repository — the same rule that lets them | |
| 196 | + | /// change its settings (`access::can_write`). | |
| 197 | + | #[tokio::test] | |
| 198 | + | async fn an_admin_may_delete_another_users_repository() { | |
| 199 | + | let h = harness().await; | |
| 200 | + | let alice = users::create(&h.app.db, "alice", "", "pw-alice-1", false) | |
| 201 | + | .await | |
| 202 | + | .unwrap(); | |
| 203 | + | let root = users::create(&h.app.db, "root", "", "pw-root-1", true) | |
| 204 | + | .await | |
| 205 | + | .unwrap(); | |
| 206 | + | repos::create( | |
| 207 | + | &h.app.db, | |
| 208 | + | &h.app.config.repositories_dir(), | |
| 209 | + | &alice, | |
| 210 | + | "proj", | |
| 211 | + | "", | |
| 212 | + | true, | |
| 213 | + | ) | |
| 214 | + | .await | |
| 215 | + | .unwrap(); | |
| 216 | + | ||
| 217 | + | let (cookie, csrf) = h.sign_in(root.id).await; | |
| 218 | + | let (status, location) = h | |
| 219 | + | .post( | |
| 220 | + | "/alice/proj/settings/delete", | |
| 221 | + | Some(&cookie), | |
| 222 | + | format!("confirm=proj&csrf={csrf}"), | |
| 223 | + | ) | |
| 224 | + | .await; | |
| 225 | + | assert_eq!(status, StatusCode::SEE_OTHER); | |
| 226 | + | assert_eq!( | |
| 227 | + | location, "/alice", | |
| 228 | + | "back to the owner's page, not the admin's" | |
| 229 | + | ); | |
| 230 | + | assert!( | |
| 231 | + | repos::find(&h.app.db, alice.id, "proj") | |
| 232 | + | .await | |
| 233 | + | .unwrap() | |
| 234 | + | .is_none() | |
| 235 | + | ); | |
| 236 | + | } |