anvilsign in

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
11 # Todo
22
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-
73
84 # Backlog
95
⋯ 21 unchanged lines
3127 references) and/or a per-attachment delete action — the recourse once a repo
3228 hits its quota. Deferred: deletion is destructive and "orphaned" is fuzzy
3329 (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)
3630 - [ ] admin usage: per-repo drill-down, and a cheap cached/periodic variant if
3731 the on-demand disk walk gets slow on large instances
3832 - [ ] periodic disk usage cache: run `usage::compute()` on a timer (e.g., hourly)
⋯ 10 unchanged lines
4943 - [ ] secrets follow-ups (docs/secrets.md): authenticate `anvild secret` with an
5044 ssh signature instead of the account password; per-step rather than per-
5145 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
105105 Ok(sessions.into_iter().take(limit).collect())
106106 }
107107
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+
108135 /// Every session that believes it still has a container — used by the sweep
109136 /// and by the startup reconcile.
110137 pub async fn live(db: &toasty::Db) -> Result<Vec<AgentSession>> {
⋯ 182 unchanged lines
modifiedcrates/anvil-core/src/attachments.rs+15 −0
⋯ 33 unchanged lines
3434 Ok(row)
3535 }
3636
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+
3752 /// Record an attachment for a repo, deduping on content: if `hash` is already
3853 /// recorded for `repo_id` the existing row is returned and no new row is made.
3954 /// 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
367367 Ok(())
368368 }
369369
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+
370392 #[cfg(test)]
371393 mod tests {
372394 use super::*;
⋯ 155 unchanged lines
modifiedcrates/anvil-core/src/issues.rs+18 −0
⋯ 129 unchanged lines
130130 Ok(comment)
131131 }
132132
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+
133151 /// An issue's comments, oldest first.
134152 pub async fn comments(db: &toasty::Db, issue_id: i64) -> Result<Vec<IssueComment>> {
135153 let mut conn = db.clone();
⋯ 46 unchanged lines
modifiedcrates/anvil-core/src/repos.rs+250 −0
⋯ 100 unchanged lines
101101 Ok(Some(repo))
102102 }
103103
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+
104185 /// Find a repository by its id.
105186 pub async fn find_by_id(db: &toasty::Db, id: i64) -> Result<Option<Repository>> {
106187 let mut db = db.clone();
⋯ 184 unchanged lines
291372 .is_err()
292373 );
293374 }
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+ }
294544 }
modifiedcrates/anvil-core/src/storage.rs+45 −0
⋯ 70 unchanged lines
7171 Ok(repo)
7272 }
7373
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+
74119 /// Point `HEAD` at `refs/heads/<branch>` as a symbolic reference.
75120 fn set_head_branch(repo: &gix::Repository, branch: &str) -> Result<()> {
76121 use gix::refs::{
⋯ 55 unchanged lines
modifiedcrates/anvil-web/src/ui.rs+114 −5
⋯ 193 unchanged lines
194194 .issue-post { margin:12px 0; }
195195 .issue-head { padding:8px 16px; border-bottom:1px solid var(--border); background:var(--code-bg); font-size:13px; color:var(--muted); }
196196 .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; }
197200 .readme { margin-top:16px; }
198201 .readme-head { padding:8px 16px; border-bottom:1px solid var(--border); background:var(--code-bg); font-size:13px; font-weight:600; }
199202 /* Todo board: a ledger, not a card wall. Each column is a hairline rail with
⋯ 222 unchanged lines
422425 "/{owner}/{repo}/settings",
423426 get(repo_settings).post(repo_settings_submit),
424427 )
428+ .route("/{owner}/{repo}/settings/delete", post(repo_delete))
425429 .route("/{owner}/{repo}", get(repo_index))
426430 .route("/{owner}/{repo}/tree/{rev}", get(tree_root))
427431 .route("/{owner}/{repo}/tree/{rev}/{*path}", get(tree_path))
⋯ 525 unchanged lines
953957 csrf: String,
954958 }
955959
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+
956969 /// `GET /new` — new-repository form (requires login).
957970 async fn new_repo_form(
958971 State(app): State<App>,
⋯ 94 unchanged lines
10531066 )
10541067 }
10551068
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.
10571071 async fn resolve_for_settings(
10581072 app: &App,
10591073 viewer: Option<&User>,
10601074 owner: &str,
10611075 name: &str,
1062-) -> Result<Repository, Response> {
1076+) -> Result<(User, Repository), Response> {
10631077 let owner_user = users::find_by_username(&app.db, owner)
10641078 .await
10651079 .map_err(server_error)?
⋯ 8 unchanged lines
10741088 if !access::can_write(&repo, viewer) {
10751089 return Err(forbidden());
10761090 }
1077- Ok(repo)
1091+ Ok((owner_user, repo))
10781092 }
10791093
10801094 /// `GET /{owner}/{repo}/settings` — owner-only repository settings.
⋯ 3 unchanged lines
10841098 csrf: Csrf,
10851099 Path((owner, repo)): Path<(String, String)>,
10861100 ) -> 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 {
10881102 Ok(m) => m,
10891103 Err(resp) => return resp,
10901104 };
⋯ 9 unchanged lines
11001114 Path((owner, repo)): Path<(String, String)>,
11011115 Form(form): Form<SettingsForm>,
11021116 ) -> 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 {
11041118 Ok(m) => m,
11051119 Err(resp) => return resp,
11061120 };
⋯ 14 unchanged lines
11211135 Redirect::to(&format!("/{owner}/{repo}")).into_response()
11221136 }
11231137
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+
11241187 fn settings_page(
11251188 user: Option<&User>,
11261189 owner: &str,
⋯ 29 unchanged lines
11561219 p { button.btn type="submit" { "Save changes" } }
11571220 }
11581221 (secrets)
1222+ (delete_section(owner, repo, meta, csrf))
11591223 },
11601224 )
11611225 }
11621226
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+
11631272 fn clone_box(app: &App, owner: &str, name: &str) -> Markup {
11641273 let http = app.config.http_clone_url(owner, name);
11651274 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+}