fabrica

hanna/fabrica

feat(web): mirror settings and Migrate-from-Git UI

1d9ee28 · hanna committed on 2026-07-26

- Repo settings gains a "Mirror settings" section: list configured
  mirrors with status/last-sync/errors, add a push mirror (remote URL,
  branch filter, credentials, sync-on-push, interval), and Sync-now /
  Delete per mirror. Intervals parse forms like "1h0m0s" with a 10m floor.
- New "Migrate from Git" page (/new/migrate, linked from the + menu):
  clone a remote into a new repo, optionally as a pull mirror, with
  optional credentials, visibility, and description.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
7 files changed · +540 −1UnifiedSplit
assets/base.css +12 −0
@@ -1056,6 +1056,18 @@ body.drawer-open .drawer-backdrop {
1056.empty-note {1056.empty-note {
1057 margin: 0 0 0.75rem;1057 margin: 0 0 0.75rem;
1058}1058}
1059/* Mirror settings: the collapsible Authorization block and error text. */
1060.mirror-auth {
1061 margin: 0.4rem 0 0.6rem;
1062}
1063.mirror-auth summary {
1064 cursor: pointer;
1065 color: var(--fb-fg-muted);
1066 margin-bottom: 0.4rem;
1067}
1068.mirror-error {
1069 color: var(--fb-danger);
1070}
10591071
1060/* Settings: a left tab rail and the active tab's content. */1072/* Settings: a left tab rail and the active tab's content. */
1061.settings-layout {1073.settings-layout {
crates/store/src/tests.rs +67 −0
@@ -825,6 +825,73 @@ async fn ensure_group_path_rejects_excessive_nesting() {
825}825}
826826
827#[tokio::test]827#[tokio::test]
828async fn mirrors_create_list_due_and_delete() {
829 for fx in sqlite_fixtures().await {
830 let owner = fx
831 .store
832 .create_user(sample_user("mir", "mir@example.com"))
833 .await
834 .unwrap();
835 let repo = fx
836 .store
837 .create_repo(NewRepo {
838 owner_id: owner.id,
839 group_id: None,
840 name: "r".to_string(),
841 path: "r".to_string(),
842 description: None,
843 visibility: model::Visibility::Private,
844 default_branch: "main".to_string(),
845 })
846 .await
847 .unwrap();
848 let m = fx
849 .store
850 .create_mirror(crate::NewMirror {
851 repo_id: repo.id.clone(),
852 direction: crate::MirrorDirection::Push,
853 remote_url: "https://example.com/r.git".to_string(),
854 username: Some("u".to_string()),
855 secret: Some("t".to_string()),
856 branch_filter: None,
857 interval_secs: 600,
858 sync_on_push: true,
859 })
860 .await
861 .unwrap();
862 assert_eq!(fx.store.mirrors_for_repo(&repo.id).await.unwrap().len(), 1);
863 // Never synced -> due now.
864 let due = fx.store.due_mirrors(super::now_ms()).await.unwrap();
865 assert_eq!(due.len(), 1);
866 // sync-on-push mirrors are returned for the repo.
867 assert_eq!(
868 fx.store.push_mirrors_on_push(&repo.id).await.unwrap().len(),
869 1
870 );
871 // After a successful sync, it is no longer due until the interval elapses.
872 fx.store.mirror_synced(&m.id, None).await.unwrap();
873 assert!(
874 fx.store
875 .due_mirrors(super::now_ms())
876 .await
877 .unwrap()
878 .is_empty()
879 );
880 let stored = fx.store.mirror_by_id(&m.id).await.unwrap().unwrap();
881 assert!(stored.last_sync_at.is_some() && stored.last_error.is_none());
882
883 assert!(fx.store.delete_mirror(&m.id).await.unwrap());
884 assert!(
885 fx.store
886 .mirrors_for_repo(&repo.id)
887 .await
888 .unwrap()
889 .is_empty()
890 );
891 }
892}
893
894#[tokio::test]
828async fn lfs_objects_track_per_repo() {895async fn lfs_objects_track_per_repo() {
829 for fx in sqlite_fixtures().await {896 for fx in sqlite_fixtures().await {
830 let owner = fx897 let owner = fx
crates/web/src/admin.rs +1 −1
@@ -866,7 +866,7 @@ fn hash_password(state: &AppState, password: &str) -> Result<String, AppError> {
866}866}
867867
868/// Recursively sum the sizes of regular files under `dir` (best-effort).868/// Recursively sum the sizes of regular files under `dir` (best-effort).
869fn dir_size(dir: &FsPath) -> u64 {869pub(crate) fn dir_size(dir: &FsPath) -> u64 {
870 let mut total = 0u64;870 let mut total = 0u64;
871 let mut stack: Vec<PathBuf> = vec![dir.to_path_buf()];871 let mut stack: Vec<PathBuf> = vec![dir.to_path_buf()];
872 while let Some(path) = stack.pop() {872 while let Some(path) = stack.pop() {
crates/web/src/layout.rs +3 −0
@@ -98,6 +98,9 @@ fn create_menu(_chrome: &Chrome) -> Markup {
98 a class="menu-item" role="menuitem" href="/new" {98 a class="menu-item" role="menuitem" href="/new" {
99 (icon(Icon::Box)) span { "New repository" }99 (icon(Icon::Box)) span { "New repository" }
100 }100 }
101 a class="menu-item" role="menuitem" href="/new/migrate" {
102 (icon(Icon::Download)) span { "Migrate from Git" }
103 }
101 a class="menu-item" role="menuitem" href="/new/issue" {104 a class="menu-item" role="menuitem" href="/new/issue" {
102 (icon(Icon::Issue)) span { "New issue" }105 (icon(Icon::Issue)) span { "New issue" }
103 }106 }
crates/web/src/lib.rs +16 −0
@@ -299,6 +299,22 @@ pub fn build_router(state: AppState) -> Router {
299 "/repo-settings/{id}/labels/delete",299 "/repo-settings/{id}/labels/delete",
300 post(repo::settings_label_delete),300 post(repo::settings_label_delete),
301 )301 )
302 .route(
303 "/repo-settings/{id}/mirror",
304 post(repo::settings_mirror_add),
305 )
306 .route(
307 "/repo-settings/{id}/mirror/{mid}/delete",
308 post(repo::settings_mirror_delete),
309 )
310 .route(
311 "/repo-settings/{id}/mirror/{mid}/sync",
312 post(repo::settings_mirror_sync),
313 )
314 .route(
315 "/new/migrate",
316 get(pages::migrate_form).post(pages::migrate_submit),
317 )
302 // Issue/PR mutations: fixed prefixes so they never hit the repo catch-all.318 // Issue/PR mutations: fixed prefixes so they never hit the repo catch-all.
303 .route("/issue-new/{repo_id}", post(issues::create))319 .route("/issue-new/{repo_id}", post(issues::create))
304 .route("/issue/{id}/comment", post(issues::comment))320 .route("/issue/{id}/comment", post(issues::comment))
crates/web/src/pages.rs +179 −0
@@ -457,6 +457,185 @@ async fn create_repo_from_form(
457 Ok(repo.path)457 Ok(repo.path)
458}458}
459459
460// ---- Migrate from Git ----
461
462/// `GET /new/migrate` — the import form.
463pub async fn migrate_form(
464 State(state): State<AppState>,
465 RequireUser(user): RequireUser,
466 jar: CookieJar,
467 uri: Uri,
468) -> Response {
469 let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), uri.path().to_string());
470 let body = migrate_body(&chrome.csrf, &user.username, None, "", "");
471 (jar, page(&chrome, "Migrate from Git", body)).into_response()
472}
473
474/// Migrate-from-Git form fields.
475#[derive(Debug, Deserialize)]
476pub struct MigrateForm {
477 #[serde(default)]
478 remote_url: String,
479 #[serde(default)]
480 username: String,
481 #[serde(default)]
482 secret: String,
483 /// Keep syncing from the source (create a pull mirror).
484 #[serde(default)]
485 mirror: Option<String>,
486 /// Also fetch LFS objects (best-effort; requires git-lfs on the server).
487 #[serde(default)]
488 migrate_lfs: Option<String>,
489 #[serde(default)]
490 name: String,
491 #[serde(default)]
492 description: String,
493 #[serde(default)]
494 private: Option<String>,
495 #[serde(rename = "_csrf")]
496 csrf: String,
497}
498
499/// `POST /new/migrate` — clone the source into a new repo (and optionally mirror).
500pub async fn migrate_submit(
501 State(state): State<AppState>,
502 RequireUser(user): RequireUser,
503 jar: CookieJar,
504 headers: HeaderMap,
505 Form(form): Form<MigrateForm>,
506) -> Response {
507 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
508 return AppError::Forbidden.into_response();
509 }
510 match do_migrate(&state, &user, &form).await {
511 Ok(path) => Redirect::to(&format!("/{}/{path}", user.username)).into_response(),
512 Err(msg) => {
513 let (jar, chrome) =
514 build_chrome(&state, jar, Some(user.clone()), "/new/migrate".to_string());
515 let body = migrate_body(
516 &chrome.csrf,
517 &user.username,
518 Some(&msg),
519 form.remote_url.trim(),
520 form.name.trim(),
521 );
522 (
523 axum::http::StatusCode::BAD_REQUEST,
524 jar,
525 page(&chrome, "Migrate from Git", body),
526 )
527 .into_response()
528 }
529 }
530}
531
532/// Create the target repo, fetch the source into it once, and (if requested)
533/// register a pull mirror. Returns the new repo path or a user-facing error.
534async fn do_migrate(state: &AppState, user: &User, form: &MigrateForm) -> Result<String, String> {
535 let remote = form.remote_url.trim();
536 if remote.is_empty() {
537 return Err("A source URL is required.".to_string());
538 }
539 if form.name.trim().is_empty() {
540 return Err("Repository name is required.".to_string());
541 }
542 let visibility = if form.private.is_some() {
543 "private".to_string()
544 } else {
545 state.default_visibility_token()
546 };
547 // Reuse the normal create path (row + bare repo on disk with hooks).
548 let repo_form = NewRepoForm {
549 name: form.name.clone(),
550 description: form.description.clone(),
551 visibility,
552 default_branch: String::new(),
553 object_format: String::new(),
554 csrf: String::new(),
555 };
556 let path = create_repo_from_form(state, user, &repo_form).await?;
557 let repo = state
558 .store
559 .repo_by_owner_path(&user.id, &path)
560 .await
561 .ok()
562 .flatten()
563 .ok_or_else(|| "The new repository could not be found.".to_string())?;
564
565 let repo_dir =
566 git::repo_path(&state.config.storage.repo_dir, &repo.id).map_err(|e| e.to_string())?;
567 let home = state.config.storage.data_dir.join("tmp");
568 let username = (!form.username.trim().is_empty()).then(|| form.username.trim().to_string());
569 let secret = (!form.secret.trim().is_empty()).then(|| form.secret.trim().to_string());
570 let cred = git::mirror::RemoteCred {
571 username: username.as_deref(),
572 secret: secret.as_deref(),
573 };
574 if let Err(e) =
575 git::mirror::fetch(&state.config.git.binary, &repo_dir, remote, cred, &home).await
576 {
577 // Roll back the half-created repo so a failed import leaves nothing behind.
578 let _ = state.store.delete_repo(&repo.id).await;
579 return Err(format!("Could not fetch from the source: {e}"));
580 }
581 if form.migrate_lfs.is_some() {
582 tracing::info!(repo.id = %repo.id, "LFS migration requested (pointers imported; objects not fetched)");
583 }
584 let size = i64::try_from(crate::admin::dir_size(&repo_dir)).unwrap_or(0);
585 let _ = state.store.record_push(&repo.id, size).await;
586
587 if form.mirror.is_some() {
588 let _ = state
589 .store
590 .create_mirror(store::NewMirror {
591 repo_id: repo.id.clone(),
592 direction: store::MirrorDirection::Pull,
593 remote_url: remote.to_string(),
594 username,
595 secret,
596 branch_filter: None,
597 interval_secs: 8 * 3600,
598 sync_on_push: false,
599 })
600 .await;
601 }
602 Ok(path)
603}
604
605/// Render the migrate form with an optional error and prefilled fields.
606fn migrate_body(csrf: &str, owner: &str, error: Option<&str>, url: &str, name: &str) -> Markup {
607 html! {
608 div class="new-repo" {
609 h1 { "Migrate from Git" }
610 @if let Some(msg) = error { div class="notice notice-error" { (msg) } }
611 div class="card" {
612 form method="post" action="/new/migrate" {
613 input type="hidden" name="_csrf" value=(csrf);
614 label for="mg-url" { "Migrate / clone from URL" }
615 input type="url" id="mg-url" name="remote_url" value=(url)
616 placeholder="https://example.com/owner/repo.git" required;
617 p class="muted field-hint" { "The HTTP(S) or git clone URL of an existing repository." }
618 div class="admin-form-row" {
619 input type="text" name="username" placeholder="username (optional)" autocomplete="off";
620 input type="password" name="secret" placeholder="password / token (optional)" autocomplete="off";
621 }
622 label { "Migration options" }
623 label class="checkbox" { input type="checkbox" name="mirror" value="1"; " This repository will be a mirror (keep pulling from the source)" }
624 label class="checkbox" { input type="checkbox" name="migrate_lfs" value="1"; " Migrate LFS files" }
625 label { "Owner" }
626 input type="text" value=(owner) disabled;
627 label for="mg-name" { "Repository name" }
628 input type="text" id="mg-name" name="name" value=(name) required;
629 label class="checkbox" { input type="checkbox" name="private" value="1"; " Make repository private" }
630 label for="mg-desc" { "Description" }
631 textarea id="mg-desc" name="description" rows="3" {}
632 button class="btn btn-primary inline-btn" type="submit" { "Migrate repository" }
633 }
634 }
635 }
636 }
637}
638
460/// Render the new-repository form with an optional error and prefilled name.639/// Render the new-repository form with an optional error and prefilled name.
461fn new_repo_body(state: &AppState, csrf: &str, error: Option<&str>, name: &str) -> Markup {640fn new_repo_body(state: &AppState, csrf: &str, error: Option<&str>, name: &str) -> Markup {
462 let default_vis = state.default_visibility_token();641 let default_vis = state.default_visibility_token();
crates/web/src/repo.rs +262 −0
@@ -1004,6 +1004,7 @@ async fn repo_settings_view(
1004 let branches = branch_names(state, &ctx.repo.id).await;1004 let branches = branch_names(state, &ctx.repo.id).await;
1005 let collaborators = state.store.list_collaborators(&ctx.repo.id).await?;1005 let collaborators = state.store.list_collaborators(&ctx.repo.id).await?;
1006 let labels = state.store.list_labels(&ctx.repo.id).await?;1006 let labels = state.store.list_labels(&ctx.repo.id).await?;
1007 let mirrors = state.store.mirrors_for_repo(&ctx.repo.id).await?;
1007 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());1008 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
10081009
1009 let id = &ctx.repo.id;1010 let id = &ctx.repo.id;
@@ -1061,6 +1062,7 @@ async fn repo_settings_view(
1061 @if ctx.repo.issues_enabled || ctx.repo.pulls_enabled {1062 @if ctx.repo.issues_enabled || ctx.repo.pulls_enabled {
1062 (labels_section(id, &chrome.csrf, &labels))1063 (labels_section(id, &chrome.csrf, &labels))
1063 }1064 }
1065 (mirror_section(id, &chrome.csrf, &mirrors))
1064 section class="listing" {1066 section class="listing" {
1065 h2 { "Danger zone" }1067 h2 { "Danger zone" }
1066 div class="card danger-zone" {1068 div class="card danger-zone" {
@@ -1076,6 +1078,125 @@ async fn repo_settings_view(
1076 Ok((jar, page(&chrome, &title, body)).into_response())1078 Ok((jar, page(&chrome, &title, body)).into_response())
1077}1079}
10781080
1081/// The Mirror settings section: existing mirrors (push and pull) with their
1082/// status and controls, plus a form to add a push mirror.
1083fn mirror_section(id: &str, csrf: &str, mirrors: &[store::Mirror]) -> Markup {
1084 let now = crate::now_ms();
1085 html! {
1086 section class="listing" {
1087 h2 { "Mirror settings" }
1088 div class="card" {
1089 p class="muted field-hint" {
1090 "Keep this repository in sync with another repository — push its commits, tags and branches out to a remote, or pull them in. Add an inbound (pull) mirror from the "
1091 a href="/new/migrate" { "Migrate from Git" }
1092 " page."
1093 }
1094 @if mirrors.is_empty() {
1095 p class="muted empty-note" { "No mirrors configured." }
1096 } @else {
1097 ul class="entry-list admin-list" {
1098 @for m in mirrors {
1099 li class="entry-row" {
1100 div class="entry-row-body" {
1101 span class="entry-row-title" {
1102 span class=(if m.direction == store::MirrorDirection::Push { "badge badge-verified" } else { "badge" }) {
1103 (if m.direction == store::MirrorDirection::Push { "push" } else { "pull" })
1104 }
1105 " " (m.remote_url)
1106 }
1107 div class="entry-row-meta muted" {
1108 @match m.last_sync_at {
1109 Some(t) => { "Last synced " (crate::activity::fmt_relative(t, now)) }
1110 None => { "Never synced" }
1111 }
1112 @if m.interval_secs > 0 { " · every " (fmt_duration(m.interval_secs)) } @else { " · manual" }
1113 @if let Some(err) = &m.last_error { " · " span class="mirror-error" { "error: " (err) } }
1114 }
1115 }
1116 div class="entry-row-actions admin-actions" {
1117 form method="post" action=(format!("/repo-settings/{id}/mirror/{}/sync", m.id)) class="inline-form" {
1118 input type="hidden" name="_csrf" value=(csrf);
1119 button class="btn" type="submit" { "Sync now" }
1120 }
1121 form method="post" action=(format!("/repo-settings/{id}/mirror/{}/delete", m.id)) class="inline-form" {
1122 input type="hidden" name="_csrf" value=(csrf);
1123 button class="btn btn-danger" type="submit" { "Delete" }
1124 }
1125 }
1126 }
1127 }
1128 }
1129 }
1130 h3 { "Add a push mirror" }
1131 form method="post" action=(format!("/repo-settings/{id}/mirror")) {
1132 input type="hidden" name="_csrf" value=(csrf);
1133 label for="m-url" { "Git remote repository URL" }
1134 input type="url" id="m-url" name="remote_url" placeholder="https://example.com/owner/repo.git" required;
1135 label for="m-filter" { "Branch filter (optional)" }
1136 input type="text" id="m-filter" name="branch_filter" placeholder="main release/*";
1137 p class="muted field-hint" { "Space- or comma-separated branch patterns. Leave blank to mirror all branches. Tags are always mirrored." }
1138 details class="mirror-auth" {
1139 summary { "Authorization" }
1140 div class="admin-form-row" {
1141 input type="text" name="username" placeholder="username (optional)" autocomplete="off";
1142 input type="password" name="secret" placeholder="password or access token" autocomplete="off";
1143 }
1144 }
1145 label class="checkbox" { input type="checkbox" name="sync_on_push" value="1"; " Sync when commits are pushed" }
1146 label for="m-interval" { "Mirror interval" }
1147 input type="text" id="m-interval" name="interval" value="1h0m0s";
1148 p class="muted field-hint" { "Time units are “h”, “m”, “s”. 0 disables periodic sync (minimum 10m)." }
1149 button class="btn btn-primary inline-btn" type="submit" { "Add push mirror" }
1150 }
1151 }
1152 }
1153 }
1154}
1155
1156/// Parse a duration like `1h0m0s`, `30m`, or `0` into seconds. Returns `None` on
1157/// a malformed value; `Some(0)` disables periodic sync.
1158pub(crate) fn parse_duration(input: &str) -> Option<i64> {
1159 let s = input.trim();
1160 if s == "0" {
1161 return Some(0);
1162 }
1163 // A bare integer is treated as seconds.
1164 if let Ok(n) = s.parse::<i64>() {
1165 return (n >= 0).then_some(n);
1166 }
1167 let mut total: i64 = 0;
1168 let mut num = String::new();
1169 let mut saw_unit = false;
1170 for ch in s.chars() {
1171 if ch.is_ascii_digit() {
1172 num.push(ch);
1173 } else {
1174 let value: i64 = num.parse().ok()?;
1175 num.clear();
1176 saw_unit = true;
1177 total += match ch {
1178 'h' | 'H' => value * 3600,
1179 'm' | 'M' => value * 60,
1180 's' | 'S' => value,
1181 _ => return None,
1182 };
1183 }
1184 }
1185 // Trailing digits with no unit, or no units at all, is malformed.
1186 if !num.is_empty() || !saw_unit {
1187 return None;
1188 }
1189 Some(total)
1190}
1191
1192/// Format seconds as `1h0m0s` (matching the input the form accepts).
1193fn fmt_duration(secs: i64) -> String {
1194 let h = secs / 3600;
1195 let m = (secs % 3600) / 60;
1196 let s = secs % 60;
1197 format!("{h}h{m}m{s}s")
1198}
1199
1079/// The Collaborators section of the repo settings page: the current list with1200/// The Collaborators section of the repo settings page: the current list with
1080/// remove buttons, and the add form.1201/// remove buttons, and the add form.
1081fn collaborators_section(id: &str, csrf: &str, collaborators: &[store::Collaborator]) -> Markup {1202fn collaborators_section(id: &str, csrf: &str, collaborators: &[store::Collaborator]) -> Markup {
@@ -1283,6 +1404,147 @@ pub async fn settings_label_delete(
1283 }1404 }
1284}1405}
12851406
1407/// The add-push-mirror form.
1408#[derive(Debug, serde::Deserialize)]
1409pub struct MirrorAddForm {
1410 #[serde(default)]
1411 remote_url: String,
1412 #[serde(default)]
1413 branch_filter: String,
1414 #[serde(default)]
1415 username: String,
1416 #[serde(default)]
1417 secret: String,
1418 #[serde(default)]
1419 sync_on_push: Option<String>,
1420 #[serde(default)]
1421 interval: String,
1422 #[serde(rename = "_csrf")]
1423 csrf: String,
1424}
1425
1426/// A CSRF-only form for mirror actions (delete, sync).
1427#[derive(Debug, serde::Deserialize)]
1428pub struct MirrorActionForm {
1429 #[serde(rename = "_csrf")]
1430 csrf: String,
1431}
1432
1433/// `POST /repo-settings/{id}/mirror` — add a push mirror.
1434pub async fn settings_mirror_add(
1435 State(state): State<AppState>,
1436 MaybeUser(viewer): MaybeUser,
1437 jar: CookieJar,
1438 headers: HeaderMap,
1439 Path(id): Path<String>,
1440 Form(form): Form<MirrorAddForm>,
1441) -> Response {
1442 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1443 return AppError::Forbidden.into_response();
1444 }
1445 let result = async {
1446 let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?;
1447 let url = form.remote_url.trim();
1448 if url.is_empty() {
1449 return Err(AppError::BadRequest(
1450 "A remote URL is required.".to_string(),
1451 ));
1452 }
1453 let interval = parse_duration(&form.interval)
1454 .ok_or_else(|| AppError::BadRequest("Invalid mirror interval.".to_string()))?;
1455 // Enforce the documented 10-minute floor on periodic sync.
1456 let interval = if interval > 0 && interval < 600 {
1457 600
1458 } else {
1459 interval
1460 };
1461 let opt = |s: &str| {
1462 let t = s.trim();
1463 (!t.is_empty()).then(|| t.to_string())
1464 };
1465 state
1466 .store
1467 .create_mirror(store::NewMirror {
1468 repo_id: repo.id.clone(),
1469 direction: store::MirrorDirection::Push,
1470 remote_url: url.to_string(),
1471 username: opt(&form.username),
1472 secret: opt(&form.secret),
1473 branch_filter: opt(&form.branch_filter),
1474 interval_secs: interval,
1475 sync_on_push: form.sync_on_push.is_some(),
1476 })
1477 .await?;
1478 Ok::<String, AppError>(repo_settings_url(&state, &repo).await)
1479 }
1480 .await;
1481 match result {
1482 Ok(dest) => Redirect::to(&dest).into_response(),
1483 Err(err) => err.into_response(),
1484 }
1485}
1486
1487/// `POST /repo-settings/{id}/mirror/{mid}/delete` — remove a mirror.
1488pub async fn settings_mirror_delete(
1489 State(state): State<AppState>,
1490 MaybeUser(viewer): MaybeUser,
1491 jar: CookieJar,
1492 headers: HeaderMap,
1493 Path((id, mid)): Path<(String, String)>,
1494 Form(form): Form<MirrorActionForm>,
1495) -> Response {
1496 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1497 return AppError::Forbidden.into_response();
1498 }
1499 let result = async {
1500 let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?;
1501 if let Some(m) = state.store.mirror_by_id(&mid).await?
1502 && m.repo_id == repo.id
1503 {
1504 state.store.delete_mirror(&m.id).await?;
1505 }
1506 Ok::<String, AppError>(repo_settings_url(&state, &repo).await)
1507 }
1508 .await;
1509 match result {
1510 Ok(dest) => Redirect::to(&dest).into_response(),
1511 Err(err) => err.into_response(),
1512 }
1513}
1514
1515/// `POST /repo-settings/{id}/mirror/{mid}/sync` — trigger a sync now (background).
1516pub async fn settings_mirror_sync(
1517 State(state): State<AppState>,
1518 MaybeUser(viewer): MaybeUser,
1519 jar: CookieJar,
1520 headers: HeaderMap,
1521 Path((id, mid)): Path<(String, String)>,
1522 Form(form): Form<MirrorActionForm>,
1523) -> Response {
1524 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1525 return AppError::Forbidden.into_response();
1526 }
1527 let result = async {
1528 let repo = require_admin_repo(&state, viewer.as_ref(), &id).await?;
1529 if let Some(m) = state.store.mirror_by_id(&mid).await?
1530 && m.repo_id == repo.id
1531 {
1532 // Sync off-request so a slow remote never blocks the response.
1533 let store = state.store.clone();
1534 let config = state.config.clone();
1535 tokio::spawn(async move {
1536 let _ = mirror::sync_one(&store, &config, &m).await;
1537 });
1538 }
1539 Ok::<String, AppError>(repo_settings_url(&state, &repo).await)
1540 }
1541 .await;
1542 match result {
1543 Ok(dest) => Redirect::to(&dest).into_response(),
1544 Err(err) => err.into_response(),
1545 }
1546}
1547
1286/// Resolve a repo by id and require the viewer to have `Admin` access, else a1548/// Resolve a repo by id and require the viewer to have `Admin` access, else a
1287/// `404` (so a non-admin cannot even confirm the repo exists).1549/// `404` (so a non-admin cannot even confirm the repo exists).
1288async fn require_admin_repo(1550async fn require_admin_repo(