//! Page: repo mirrors (`/{owner}/{repo}/settings/mirrors`). Admins of the repo list its pull and push //! mirrors, add push mirrors, edit any mirror, sync one now and delete one. Anyone else gets a 404, so //! the page does not reveal that a repo mirrors anything. A mirror's password is sealed with the //! instance key and is write-only: it is never rendered, a blank password on edit keeps it, and the //! "clear" box removes it. URLs are validated with the webhook policy before they are saved and are //! shown redacted (host and path only) everywhere. use forge_core::AuditAction; use forge_core::mirrors::{ DEFAULT_INTERVAL_MINUTES, MIN_INTERVAL_MINUTES, Mirror, MirrorDirection, NewMirror, SecretUpdate, }; use forge_core::time::now_stamp; use forge_core::tz::format_in_tz; use serde::Deserialize; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::{SeeOther, forbidden, not_found, see_other}; use topcoat::router::request::uri; use topcoat::router::{page, path_param, route}; use topcoat::view::{View, component, view}; use crate::audit::{self, Actor, Target}; use crate::auth::{require_csrf, require_user}; use crate::i18n::{ self, Lang, integrations::{self as g, K}, }; use crate::pages::refs::load_refs; use crate::pages::repo::repo_head; use crate::pages::repo_ctx::{RepoCtx, RepoHead, load_repo}; use crate::pages::repo_settings::settings_nav_for; use crate::state::AppState; use crate::views::{alert, enc, shell, user_facing_message}; use crate::webhooks::client::{WebhookPolicy, validate_url}; path_param!(owner); path_param!(repo); path_param!(mirror_id); /// Longest username accepted, in characters. const MAX_USERNAME_CHARS: usize = 255; /// Longest password or token accepted, in characters. const MAX_SECRET_CHARS: usize = 1024; /// Longest sync interval accepted: one year. const MAX_INTERVAL_MINUTES: u32 = 525_600; /// Notice words (the only text a `?notice=` query can show), their text key, and whether they are errors. const NOTICES: [(&str, K, bool); 7] = [ ("created", K::NoticeCreated, false), ("saved", K::NoticeSaved, false), ("synced", K::NoticeSynced, false), ("deleted", K::NoticeDeleted, false), ("pull-created", K::NoticePullCreated, false), ("pull-not-empty", K::NoticePullNotEmpty, true), ("confirm", K::NoticeConfirm, true), ]; /// The add form for a push mirror, or for a pull mirror when `direction` is `pull`. #[derive(Deserialize)] struct PushForm { #[serde(default)] csrf: String, #[serde(default)] direction: String, #[serde(default)] url: String, #[serde(default)] username: String, #[serde(default)] secret: String, #[serde(default)] interval: String, } /// The edit form. A blank `url` keeps the stored URL, a blank `secret` keeps the stored password. #[derive(Deserialize)] struct EditForm { #[serde(default)] csrf: String, #[serde(default)] url: String, #[serde(default)] username: String, #[serde(default)] secret: String, #[serde(default)] clear_secret: Option, #[serde(default)] interval: String, #[serde(default)] enabled: Option, } #[derive(Deserialize)] struct CsrfForm { #[serde(default)] csrf: String, } #[derive(Deserialize)] struct DeleteForm { #[serde(default)] csrf: String, #[serde(default)] confirm: Option, } /// The values of a checked mirror form, ready for the store. The URL is the validated one. pub struct Checked { pub url: String, pub username: String, pub secret: String, pub interval: u32, } /// The webhook policy from config. A bad `[webhooks]` section is a server error, not a user error. fn policy(state: &AppState) -> Result { Ok(WebhookPolicy::from_config(&state.config.webhooks).map_err(forge_core::Error::Config)?) } /// The URL as the UI may show it: host, port and path only. Userinfo, query and fragment often carry /// credentials or tokens, so they are dropped. An unreadable URL shows a fixed placeholder. pub fn redacted_url(raw: &str) -> String { match reqwest::Url::parse(raw) { Ok(url) => { let host = url.host_str().unwrap_or_default(); let port = url.port().map(|p| format!(":{p}")).unwrap_or_default(); format!("{host}{port}{}", url.path()) } Err(_) => "(unreadable URL)".to_owned(), } } /// Reads a stored RFC 3339 stamp and shows it in the viewer's timezone. An unreadable stamp is shown /// as it is. pub fn stamp_in_tz(stamp: &str, tz: &str) -> String { match OffsetDateTime::parse(stamp, &Rfc3339) { Ok(at) => format_in_tz(at.unix_timestamp(), tz), Err(_) => stamp.to_owned(), } } /// Parses the interval field: whole minutes, `0` for manual only, otherwise at least the minimum. /// A blank field takes the default. pub fn parse_interval(lang: Lang, raw: &str) -> std::result::Result { let raw = raw.trim(); if raw.is_empty() { return Ok(DEFAULT_INTERVAL_MINUTES); } let minutes: u32 = raw .parse() .map_err(|_| g::t(lang, K::ErrIntervalNotWhole).to_owned())?; if minutes != 0 && minutes < MIN_INTERVAL_MINUTES { return Err(g::interval_too_short(lang, MIN_INTERVAL_MINUTES)); } if minutes > MAX_INTERVAL_MINUTES { return Err(g::t(lang, K::ErrIntervalMax).to_owned()); } Ok(minutes) } /// Checks a mirror form, with its messages in `lang`. The URL goes through the webhook policy /// (https only, no userinfo, no loopback or private addresses unless the instance allows them). /// The message is shown to the user, so it names the field and never echoes the URL. pub fn check_form_in( lang: Lang, state: &AppState, url: &str, username: &str, secret: &str, interval: &str, ) -> std::result::Result { let url = url.trim(); if url.is_empty() { return Err(g::t(lang, K::ErrUrlRequired).to_owned()); } let policy = policy(state).map_err(|e| e.to_string())?; validate_url(url, &policy).map_err(|e| e.to_string())?; let username = username.trim(); if username.chars().count() > MAX_USERNAME_CHARS || username.contains(':') { return Err(g::username_too_long(lang, MAX_USERNAME_CHARS)); } let secret = secret.trim(); if secret.chars().count() > MAX_SECRET_CHARS { return Err(g::password_too_long(lang, MAX_SECRET_CHARS)); } Ok(Checked { url: url.to_owned(), username: username.to_owned(), secret: secret.to_owned(), interval: parse_interval(lang, interval)?, }) } /// The viewer's language for a repo page. fn lang_of(cx: &Cx, ctx: &RepoCtx) -> Lang { i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user)) } fn title_of(ctx: &RepoCtx) -> String { format!("{}/{}", ctx.repo.owner_name, ctx.repo.name) } fn mirrors_href(title: &str) -> String { format!("/{title}/settings/mirrors") } fn mirror_href(title: &str, id: &str) -> String { format!("{}/{}", mirrors_href(title), enc(id)) } /// The notice from `?notice=`. Anything else is ignored. fn notice_from_query(cx: &Cx, lang: Lang) -> Option<(&'static str, bool)> { let query = uri(cx).query()?; let word = form_urlencoded::parse(query.as_bytes()) .find(|(key, _)| key == "notice") .map(|(_, value)| value.into_owned())?; NOTICES .iter() .find(|(name, _, _)| *name == word) .map(|(_, key, is_error)| (g::t(lang, *key), *is_error)) } fn back_to_list(title: &str, notice: &str) -> SeeOther { see_other(format!("{}?notice={notice}", mirrors_href(title))) } fn back_to_mirror(title: &str, id: &str, notice: &str) -> SeeOther { see_other(format!("{}?notice={notice}", mirror_href(title, id))) } fn direction_label(lang: Lang, direction: MirrorDirection) -> &'static str { match direction { MirrorDirection::Pull => g::t(lang, K::DirectionPull), MirrorDirection::Push => g::t(lang, K::DirectionPush), } } /// "Pull mirror" or "Push mirror", the heading of an edit page. fn mirror_kind(lang: Lang, direction: MirrorDirection) -> &'static str { match direction { MirrorDirection::Pull => g::t(lang, K::PullMirror), MirrorDirection::Push => g::t(lang, K::PushMirror), } } /// "every 8 h", "every 90 min" or "manual only". fn interval_text(lang: Lang, minutes: u32) -> String { match minutes { 0 => g::t(lang, K::ManualOnly).to_owned(), m if m % 60 == 0 => g::every_hours(lang, m / 60), m => g::every_minutes(lang, m), } } /// The last sync, as the list and the header show it: ok, failed (with the error) or not run yet. fn status_of(lang: Lang, mirror: &Mirror) -> (&'static str, &'static str) { match mirror.last_ok { Some(true) => ("ok", g::t(lang, K::StatusOk)), Some(false) => ("failed", g::t(lang, K::StatusFailed)), None => ("none", g::t(lang, K::StatusNotRun)), } } /// The repo fields the mirror pages show, read once from the repo context. struct Frame { page_title: String, title: String, archived: bool, tz: String, lang: Lang, head: RepoHead, } impl Frame { async fn new(ctx: &RepoCtx, state: &AppState, lang: Lang) -> Result { let title = title_of(ctx); Ok(Self { page_title: format!("{title} \u{b7} {}", g::t(lang, K::Mirrors)), title, archived: ctx.repo.archived, tz: ctx.viewer_tz().to_owned(), lang, head: ctx.head(state, "settings").await?, }) } } /// The values an add form shows, plus its error. struct PushDraft { url: String, username: String, interval: String, error: Option, } impl PushDraft { fn blank() -> Self { Self { url: String::new(), username: String::new(), interval: DEFAULT_INTERVAL_MINUTES.to_string(), error: None, } } } /// One mirror row on the list page, formatted for display. struct MirrorRow { href: String, sync_action: String, direction: &'static str, url: String, interval: String, enabled: bool, last_sync: String, status_class: &'static str, status_text: &'static str, error: String, } fn mirror_row(lang: Lang, mirror: &Mirror, title: &str, tz: &str) -> MirrorRow { let (status_class, status_text) = status_of(lang, mirror); MirrorRow { href: mirror_href(title, &mirror.id), sync_action: format!("{}/sync", mirror_href(title, &mirror.id)), direction: direction_label(lang, mirror.direction), url: redacted_url(&mirror.url), interval: interval_text(lang, mirror.interval_minutes), enabled: mirror.enabled, last_sync: mirror .last_sync_at .as_deref() .map(|stamp| stamp_in_tz(stamp, tz)) .unwrap_or_default(), status_class, status_text, error: mirror.last_error.clone().unwrap_or_default(), } } /// The list rows for the repo's mirrors. async fn rows_of(state: &AppState, ctx: &RepoCtx, frame: &Frame) -> Result> { Ok(state .store .list_mirrors(&ctx.repo.id) .await? .iter() .map(|mirror| mirror_row(frame.lang, mirror, &frame.title, &frame.tz)) .collect()) } /// Header, sub-navigation and the one-line notice. #[component] async fn frame_top( head: RepoHead, title: String, notice: Option<(&'static str, bool)>, archived: bool, lang: Lang, ) -> Result { Ok(view! { repo_head(head: head) settings_nav_for(lang: lang, title: title, current: "mirrors") if let Some((text, is_error)) = notice { if is_error { alert(message: text) } else {
(text)
} } if archived {
(g::t(lang, K::ArchivedNotice))
} }) } /// The fields shared by the add and edit forms. `current_url` is the redacted stored URL on edit. /// `direction` is sent as a hidden field by the add-pull form only (empty for the others). #[component] async fn mirror_form( action: String, csrf: String, pull: bool, current_url: String, url: String, username: String, has_secret: bool, interval: String, enabled: Option, error: Option, submit: &'static str, direction: &'static str, lang: Lang, ) -> Result { let url_label = if pull { g::t(lang, K::RepositoryUrl) } else { g::t(lang, K::RemoteUrl) }; let url_hint = if current_url.is_empty() { g::t(lang, K::UrlHintNew) } else { g::t(lang, K::UrlHintKeep) }; let secret_hint = if has_secret { g::t(lang, K::SecretSet) } else { g::t(lang, K::SecretUnset) }; Ok(view! {
if let Some(message) = error { alert(message: message.as_str()) } if !direction.is_empty() { } if !current_url.is_empty() {

(g::t(lang, K::CurrentUrl))" "(current_url.as_str())

}

(url_hint)

(secret_hint)

if has_secret { } if let Some(on) = enabled { }
}) } #[component] async fn mirrors_list_body( head: RepoHead, title: String, notice: Option<(&'static str, bool)>, archived: bool, csrf: String, rows: Vec, draft: PushDraft, pull_form: Option, lang: Lang, ) -> Result { let add_action = mirrors_href(&title); Ok(view! { frame_top(head: head, title: title.clone(), notice: notice, archived: archived, lang: lang)

(g::t(lang, K::Mirrors))

(g::t(lang, K::MirrorsIntro))

if rows.is_empty() {

(g::t(lang, K::NoMirrors))

} else {
for row in rows.iter() { }
(g::t(lang, K::ColDirection)) "URL" (g::t(lang, K::ColInterval)) (g::t(lang, K::ColEnabled)) (g::t(lang, K::ColLastSync)) (g::t(lang, K::ColResult))
(row.direction) (row.url.as_str()) (row.interval.as_str()) (if row.enabled { g::t(lang, K::Yes) } else { g::t(lang, K::No) }) (row.last_sync.as_str()) (row.status_text) if !row.error.is_empty() {
(g::t(lang, K::ErrorLabel))
(row.error.as_str())
}
if !archived {
}
}
if !archived { if let Some(pull) = pull_form {

(g::t(lang, K::AddPullMirror))

(g::t(lang, K::AddPullIntro))

mirror_form(action: add_action.clone(), csrf: csrf.clone(), pull: true, current_url: String::new(), url: pull.url, username: pull.username, has_secret: false, interval: pull.interval, enabled: None, error: pull.error, submit: g::t(lang, K::AddPullMirror), direction: "pull", lang: lang)
}

(g::t(lang, K::AddPushMirror))

(g::t(lang, K::AddPushIntro))

mirror_form(action: add_action.clone(), csrf: csrf.clone(), pull: false, current_url: String::new(), url: draft.url, username: draft.username, has_secret: false, interval: draft.interval, enabled: None, error: draft.error, submit: g::t(lang, K::AddPushMirror), direction: "", lang: lang)
} }) } /// Lists the repo's mirrors. Admins only: anyone else gets 404. #[page("/{owner}/{repo}/settings/mirrors")] async fn mirrors_list_page(cx: &Cx) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let (_, session) = require_user(cx).await?; if !ctx.can_admin() { return Err(not_found().into()); } let state = app_context::(cx); let lang = lang_of(cx, &ctx); let frame = Frame::new(&ctx, state, lang).await?; let rows = rows_of(state, &ctx, &frame).await?; let csrf = session.csrf_token.clone(); let notice = notice_from_query(cx, lang); let pull_form = can_add_pull(state, &ctx).await?.then(PushDraft::blank); Ok(view! { shell(title: frame.page_title.as_str(), viewer: ctx.viewer.as_ref(), mirrors_list_body(head: frame.head, title: frame.title, notice: notice, archived: frame.archived, csrf: csrf, rows: rows, draft: PushDraft::blank(), pull_form: pull_form, lang: lang)) }) } /// The list page's rows and frame, for a page built by a handler. async fn list_data(state: &AppState, ctx: &RepoCtx, lang: Lang) -> Result<(Frame, Vec)> { let frame = Frame::new(ctx, state, lang).await?; let rows = rows_of(state, ctx, &frame).await?; Ok((frame, rows)) } /// Whether the page offers the add-pull-mirror form: the repo has no pull mirror and no refs. async fn can_add_pull(state: &AppState, ctx: &RepoCtx) -> Result { if state.store.pull_mirror(&ctx.repo.id).await?.is_some() { return Ok(false); } Ok(load_refs(ctx.path.clone()).await?.is_empty()) } /// Checks the form and stores a mirror of `direction`. `Ok(Err(message))` is a refusal the form /// shows. A new pull mirror is scheduled now and wakes the worker; a push mirror waits for its /// first push or interval, as before. async fn store_mirror( lang: Lang, state: &AppState, ctx: &RepoCtx, direction: MirrorDirection, form: &PushForm, ) -> Result> { let checked = match check_form_in( lang, state, &form.url, &form.username, &form.secret, &form.interval, ) { Ok(checked) => checked, Err(message) => return Ok(Err(message)), }; let secret = (!checked.secret.is_empty()).then_some(checked.secret.as_bytes()); let created = state .store .create_mirror(&NewMirror { repo_id: &ctx.repo.id, direction, url: &checked.url, username: &checked.username, secret, interval_minutes: checked.interval, }) .await; let mirror = match created { Ok(mirror) => mirror, Err(e) => match user_facing_message(&e) { Some(message) => return Ok(Err(message)), None => return Err(e.into()), }, }; if direction == MirrorDirection::Pull { state .store .schedule_mirror(&mirror.id, &now_stamp()) .await?; } // A new mirror is due now, push or pull. state.mirror_waker.notify_one(); Ok(Ok(mirror)) } /// Stores a push mirror. async fn add_push_mirror( lang: Lang, state: &AppState, ctx: &RepoCtx, form: &PushForm, ) -> Result> { store_mirror(lang, state, ctx, MirrorDirection::Push, form).await } /// Stores the pull mirror of an empty repo. A repo with refs is refused with a notice. async fn add_pull_mirror( lang: Lang, state: &AppState, ctx: &RepoCtx, form: &PushForm, ) -> Result> { if !load_refs(ctx.path.clone()).await?.is_empty() { return Err(back_to_list(&title_of(ctx), "pull-not-empty").into()); } store_mirror(lang, state, ctx, MirrorDirection::Pull, form).await } /// Adds a push mirror, or a pull mirror when the form says `direction=pull`. The URL is checked /// before anything is stored. #[page(POST "/{owner}/{repo}/settings/mirrors")] async fn mirror_create(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let (user, session) = require_csrf(cx, &form.csrf).await?; if !ctx.can_admin() { return Err(not_found().into()); } if ctx.repo.archived { return Err(forbidden().into()); } let state = app_context::(cx); let lang = lang_of(cx, &ctx); let pull = form.direction == "pull"; let stored = if pull { add_pull_mirror(lang, state, &ctx, &form).await? } else { add_push_mirror(lang, state, &ctx, &form).await? }; let mirror = match stored { Ok(mirror) => mirror, Err(message) => { let (frame, rows) = list_data(state, &ctx, lang).await?; let csrf = session.csrf_token.clone(); let typed = PushDraft { url: form.url.trim().to_owned(), username: form.username.trim().to_owned(), interval: form.interval.trim().to_owned(), error: Some(message), }; let (draft, pull_form) = if pull { (PushDraft::blank(), Some(typed)) } else { ( typed, can_add_pull(state, &ctx).await?.then(PushDraft::blank), ) }; return Ok(view! { shell(title: frame.page_title.as_str(), viewer: ctx.viewer.as_ref(), mirrors_list_body(head: frame.head, title: frame.title, notice: None, archived: frame.archived, csrf: csrf, rows: rows, draft: draft, pull_form: pull_form, lang: lang)) }); } }; audit::record( &state.store, Some(cx), Actor::User(&user), AuditAction::MirrorCreated, Target { kind: "repo", id: &ctx.repo.id, name: &title_of(&ctx), }, serde_json::json!({ "mirror_id": mirror.id, "direction": mirror.direction.as_str(), "url": redacted_url(&mirror.url), }), ) .await; tracing::info!(actor_id = %user.id, repo_id = %ctx.repo.id, mirror_id = %mirror.id, direction = %mirror.direction.as_str(), "mirror created"); let notice = if pull { "pull-created" } else { "created" }; Err(back_to_list(&title_of(&ctx), notice).into()) } /// The mirror in the path, when it belongs to this repo. Any other mirror answers 404. async fn repo_mirror(state: &AppState, ctx: &RepoCtx, id: &str) -> Result { match state.store.mirror(id).await? { Some(mirror) if mirror.repo_id == ctx.repo.id => Ok(mirror), _ => Err(not_found().into()), } } /// The edit page body: the form, the sync and delete controls. #[component] async fn mirror_body( head: RepoHead, title: String, notice: Option<(&'static str, bool)>, archived: bool, csrf: String, mirror_id: String, mirror: Mirror, draft: EditDraft, lang: Lang, ) -> Result { let edit_action = mirror_href(&title, &mirror_id); let sync_action = format!("{edit_action}/sync"); let delete_action = format!("{edit_action}/delete"); let pull = mirror.direction == MirrorDirection::Pull; let last_sync = mirror .last_sync_at .as_deref() .map(|stamp| stamp_in_tz(stamp, &draft.tz)) .unwrap_or_else(|| g::t(lang, K::Never).to_owned()); let delete_text = if pull { g::t(lang, K::DeleteTextPull) } else { g::t(lang, K::DeleteTextPush) }; let current_url = redacted_url(&mirror.url); Ok(view! { frame_top(head: head, title: title.clone(), notice: notice, archived: archived, lang: lang)

(mirror_kind(lang, mirror.direction))

if archived {

(current_url.as_str())

(g::t(lang, K::LastSync))" " (last_sync.as_str())

if let Some(error) = mirror.last_error.clone() {
(error.as_str())
} } else { mirror_form(action: edit_action.clone(), csrf: csrf.clone(), pull: pull, current_url: current_url.clone(), url: String::new(), username: draft.username, has_secret: mirror.has_secret, interval: draft.interval, enabled: Some(draft.enabled), error: draft.error, submit: g::t(lang, K::SaveMirror), direction: "", lang: lang) }
if !archived {

(g::t(lang, K::SyncHeading))

(g::t(lang, K::SyncIntro))

(g::t(lang, K::DeleteMirror))

(delete_text)

} }) } /// The edit form's values: the username and the interval as stored, or as submitted on error. struct EditDraft { tz: String, username: String, interval: String, enabled: bool, error: Option, } impl EditDraft { fn of_mirror(mirror: &Mirror, tz: &str) -> Self { Self { tz: tz.to_owned(), username: mirror.username.clone(), interval: mirror.interval_minutes.to_string(), enabled: mirror.enabled, error: None, } } } /// The edit page for one mirror. #[page("/{owner}/{repo}/settings/mirrors/{mirror_id}")] async fn mirror_page(cx: &Cx) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let id = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let (_, session) = require_user(cx).await?; if !ctx.can_admin() { return Err(not_found().into()); } let state = app_context::(cx); let mirror = repo_mirror(state, &ctx, id).await?; let lang = lang_of(cx, &ctx); let frame = Frame::new(&ctx, state, lang).await?; let draft = EditDraft::of_mirror(&mirror, &frame.tz); let csrf = session.csrf_token.clone(); let notice = notice_from_query(cx, lang); let mirror_id = mirror.id.clone(); Ok(view! { shell(title: frame.page_title.as_str(), viewer: ctx.viewer.as_ref(), mirror_body(head: frame.head, title: frame.title, notice: notice, archived: frame.archived, csrf: csrf, mirror_id: mirror_id, mirror: mirror, draft: draft, lang: lang)) }) } /// Saves a mirror. A blank URL keeps the stored one, a blank password keeps the stored password and /// the clear box removes it. A refused form shows the error on the edit page. #[page(POST "/{owner}/{repo}/settings/mirrors/{mirror_id}")] async fn mirror_update(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let id = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let (user, session) = require_csrf(cx, &form.csrf).await?; if !ctx.can_admin() { return Err(not_found().into()); } if ctx.repo.archived { return Err(forbidden().into()); } let state = app_context::(cx); let mirror = repo_mirror(state, &ctx, id).await?; let url = if form.url.trim().is_empty() { mirror.url.clone() } else { form.url.trim().to_owned() }; let lang = lang_of(cx, &ctx); let checked = check_form_in( lang, state, &url, &form.username, &form.secret, &form.interval, ); let checked = match checked { Ok(checked) => checked, Err(message) => { let frame = Frame::new(&ctx, state, lang).await?; let draft = EditDraft { tz: frame.tz.clone(), username: form.username.trim().to_owned(), interval: form.interval.trim().to_owned(), enabled: form.enabled.is_some(), error: Some(message), }; let csrf = session.csrf_token.clone(); let mirror_id = mirror.id.clone(); return Ok(view! { shell(title: frame.page_title.as_str(), viewer: ctx.viewer.as_ref(), mirror_body(head: frame.head, title: frame.title, notice: None, archived: frame.archived, csrf: csrf, mirror_id: mirror_id, mirror: mirror, draft: draft, lang: lang)) }); } }; let secret_update = if !form.secret.trim().is_empty() { SecretUpdate::Set(checked.secret.as_bytes()) } else if form.clear_secret.is_some() { SecretUpdate::Clear } else { SecretUpdate::Keep }; let cleared = matches!(secret_update, SecretUpdate::Clear); state .store .update_mirror( &mirror.id, &checked.url, &checked.username, secret_update, checked.interval, form.enabled.is_some(), ) .await?; // The interval and the enabled flag decide when this mirror runs next. state.mirror_waker.notify_one(); audit::record( &state.store, Some(cx), Actor::User(&user), AuditAction::MirrorUpdated, Target { kind: "repo", id: &ctx.repo.id, name: &title_of(&ctx), }, serde_json::json!({ "mirror_id": mirror.id, "direction": mirror.direction.as_str(), "url": redacted_url(&checked.url), "secret_changed": !matches!(secret_update, SecretUpdate::Keep), "secret_cleared": cleared, }), ) .await; tracing::info!(actor_id = %user.id, repo_id = %ctx.repo.id, mirror_id = %mirror.id, "mirror updated"); Err(back_to_mirror(&title_of(&ctx), &mirror.id, "saved").into()) } /// Queues a sync now: the mirror is due at the current time and the worker is woken. #[route(POST "/{owner}/{repo}/settings/mirrors/{mirror_id}/sync")] async fn mirror_sync(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let id = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; require_csrf(cx, &form.csrf).await?; if !ctx.can_admin() { return Err(not_found().into()); } if ctx.repo.archived { return Err(forbidden().into()); } let state = app_context::(cx); let mirror = repo_mirror(state, &ctx, id).await?; state .store .schedule_mirror(&mirror.id, &now_stamp()) .await?; state.mirror_waker.notify_one(); Ok(back_to_list(&title_of(&ctx), "synced")) } /// Deletes a mirror. The box must be ticked. Deleting the pull mirror makes the repo a normal one. #[route(POST "/{owner}/{repo}/settings/mirrors/{mirror_id}/delete")] async fn mirror_delete(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let id = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let (user, _) = require_csrf(cx, &form.csrf).await?; if !ctx.can_admin() { return Err(not_found().into()); } if ctx.repo.archived { return Err(forbidden().into()); } let state = app_context::(cx); let mirror = repo_mirror(state, &ctx, id).await?; let title = title_of(&ctx); if form.confirm.is_none() { return Ok(back_to_mirror(&title, &mirror.id, "confirm")); } state.store.delete_mirror(&mirror.id).await?; audit::record( &state.store, Some(cx), Actor::User(&user), AuditAction::MirrorDeleted, Target { kind: "repo", id: &ctx.repo.id, name: &title, }, serde_json::json!({ "mirror_id": mirror.id, "direction": mirror.direction.as_str(), "url": redacted_url(&mirror.url), }), ) .await; tracing::info!(actor_id = %user.id, repo_id = %ctx.repo.id, mirror_id = %mirror.id, "mirror deleted"); Ok(back_to_list(&title, "deleted")) } #[cfg(test)] mod tests { use std::path::Path; use forge_core::mirrors::{MirrorDirection, NewMirror}; use forge_core::time::now_stamp; use forge_core::{Role, Store, Visibility}; use forge_git::repo::{init_bare, repo_path}; use topcoat::router::request::Request; use topcoat::router::{Body, Router, to_bytes}; use super::redacted_url; use crate::auth::SESSION_COOKIE; use crate::test_support::{app_router, create_session, test_state}; const CSRF: &str = "mirrors-test-csrf"; const REMOTE: &str = "https://example.com/upstream/proj.git"; /// Alice owns `proj` (private), bob is a Write collaborator, carol is outside the repo. async fn fixture(dir: &Path) -> (Store, Router, forge_core::Repo) { let state = test_state(dir).await; let store = state.store.clone(); let alice = store .create_user("alice", None, "unused", false) .await .expect("alice"); let bob = store .create_user("bob", None, "unused", false) .await .expect("bob"); store .create_user("carol", None, "unused", false) .await .expect("carol"); let repo = store .create_repo(&alice.id, "proj", "", Visibility::Private, "main") .await .expect("repo"); let bare = repo_path(&state.git.data_dir, &repo.id).expect("repo path"); init_bare(&state.git, &bare, "main") .await .expect("init bare"); store .set_collaborator(&repo.id, &bob.id, Role::Write) .await .expect("collaborator"); create_session(&store, &alice.id, "alice-token", CSRF).await; create_session(&store, &bob.id, "bob-token", CSRF).await; let carol = store .user_by_username("carol") .await .expect("lookup") .expect("carol"); create_session(&store, &carol.id, "carol-token", CSRF).await; (store, app_router(state), repo) } fn cookie_for(token: &str) -> String { format!("__Host-{SESSION_COOKIE}={token}") } /// Sends a request. A POST carries the CSRF token and the given fields as a form body. /// Returns the status, the Location header (if any) and the body. async fn send( app: &Router, method: &str, uri: &str, cookie: &str, fields: &[(&str, &str)], ) -> (u16, Option, String) { let mut form = form_urlencoded::Serializer::new(String::new()); if method == "POST" { form.append_pair("csrf", CSRF); } for (name, value) in fields { form.append_pair(name, value); } let mut builder = Request::builder() .method(method) .uri(uri) .header("cookie", cookie); if method == "POST" { builder = builder.header("content-type", "application/x-www-form-urlencoded"); } let req = builder.body(Body::from(form.finish())).expect("request"); let res = app.handle(req).await; let status = res.status().as_u16(); let location = res .headers() .get("location") .and_then(|value| value.to_str().ok()) .map(str::to_owned); let body = to_bytes(res.into_body(), usize::MAX).await.expect("body"); ( status, location, String::from_utf8_lossy(&body).into_owned(), ) } async fn push_mirror(store: &Store, repo: &forge_core::Repo, secret: Option<&[u8]>) -> String { store .create_mirror(&NewMirror { repo_id: &repo.id, direction: MirrorDirection::Push, url: "https://example.com/remote.git?token=abc123", username: "bot", secret, interval_minutes: 480, }) .await .expect("mirror") .id } #[test] fn redacted_url_keeps_host_port_and_path_only() { assert_eq!( redacted_url("https://example.com:8443/a/b.git?token=abc#frag"), "example.com:8443/a/b.git" ); assert_eq!(redacted_url(REMOTE), "example.com/upstream/proj.git"); assert_eq!(redacted_url("not a url"), "(unreadable URL)"); } #[tokio::test] async fn non_admins_get_not_found() { let dir = tempfile::tempdir().expect("tempdir"); let (store, app, repo) = fixture(dir.path()).await; let id = push_mirror(&store, &repo, None).await; for token in ["bob-token", "carol-token"] { let (status, _, _) = send( &app, "GET", "/alice/proj/settings/mirrors", &cookie_for(token), &[], ) .await; assert_eq!(status, 404, "list as {token}"); let (status, _, _) = send( &app, "GET", &format!("/alice/proj/settings/mirrors/{id}"), &cookie_for(token), &[], ) .await; assert_eq!(status, 404, "edit as {token}"); let (status, _, _) = send( &app, "POST", &format!("/alice/proj/settings/mirrors/{id}/sync"), &cookie_for(token), &[], ) .await; assert_eq!(status, 404, "sync as {token}"); } let (status, _, _) = send( &app, "GET", "/alice/proj/settings/mirrors", &cookie_for("alice-token"), &[], ) .await; assert_eq!(status, 200); } #[tokio::test] async fn list_page_follows_the_stored_turkish_language() { let dir = tempfile::tempdir().expect("tempdir"); let (store, app, _repo) = fixture(dir.path()).await; let alice = store .user_by_username("alice") .await .expect("lookup") .expect("alice"); store .set_ui_prefs(&alice.id, "auto", "tr") .await .expect("set language"); let (status, _, body) = send( &app, "GET", "/alice/proj/settings/mirrors", &cookie_for("alice-token"), &[], ) .await; assert_eq!(status, 200, "{body}"); assert!( body.contains("

Yansılar

"), "Turkish heading missing" ); } #[tokio::test] async fn secret_is_never_rendered_and_urls_are_redacted() { let dir = tempfile::tempdir().expect("tempdir"); let (store, app, repo) = fixture(dir.path()).await; let id = push_mirror(&store, &repo, Some(b"hunter2-secret")).await; let cookie = cookie_for("alice-token"); for uri in [ "/alice/proj/settings/mirrors".to_owned(), format!("/alice/proj/settings/mirrors/{id}"), ] { let (status, _, body) = send(&app, "GET", &uri, &cookie, &[]).await; assert_eq!(status, 200, "{uri}"); assert!(!body.contains("hunter2-secret"), "secret rendered on {uri}"); assert!(!body.contains("abc123"), "query string rendered on {uri}"); assert!( body.contains("example.com/remote.git"), "redacted URL missing on {uri}" ); } assert!( store .mirror(&id) .await .expect("query") .expect("mirror") .has_secret ); } #[tokio::test] async fn loopback_url_is_rejected_by_the_form() { let dir = tempfile::tempdir().expect("tempdir"); let (store, app, repo) = fixture(dir.path()).await; let (status, _, body) = send( &app, "POST", "/alice/proj/settings/mirrors", &cookie_for("alice-token"), &[("url", "https://127.0.0.1/remote.git"), ("interval", "480")], ) .await; assert_eq!(status, 200); assert!(body.contains("destination not allowed"), "error not shown"); assert!(store.list_mirrors(&repo.id).await.expect("list").is_empty()); } #[tokio::test] async fn add_push_mirror_then_sync_now_schedules_it() { let dir = tempfile::tempdir().expect("tempdir"); let (store, app, repo) = fixture(dir.path()).await; let cookie = cookie_for("alice-token"); let (status, location, _) = send( &app, "POST", "/alice/proj/settings/mirrors", &cookie, &[ ("url", REMOTE), ("username", "bot"), ("secret", "hunter2-secret"), ("interval", "60"), ], ) .await; assert_eq!(status, 303); assert!(location.unwrap_or_default().contains("notice=created")); let mirrors = store.list_mirrors(&repo.id).await.expect("list"); assert_eq!(mirrors.len(), 1); let mirror = &mirrors[0]; assert_eq!(mirror.direction, MirrorDirection::Push); assert_eq!(mirror.url, REMOTE); assert!(mirror.has_secret); let (status, _, _) = send( &app, "POST", &format!("/alice/proj/settings/mirrors/{}/sync", mirror.id), &cookie, &[], ) .await; assert_eq!(status, 303); let synced = store .mirror(&mirror.id) .await .expect("query") .expect("mirror"); assert!( synced.next_sync_at <= now_stamp(), "sync now makes the mirror due" ); } #[tokio::test] async fn edit_clears_the_password_on_request() { let dir = tempfile::tempdir().expect("tempdir"); let (store, app, repo) = fixture(dir.path()).await; let id = push_mirror(&store, &repo, Some(b"hunter2-secret")).await; let (status, _, _) = send( &app, "POST", &format!("/alice/proj/settings/mirrors/{id}"), &cookie_for("alice-token"), &[ ("url", ""), ("username", "bot"), ("interval", "480"), ("enabled", "on"), ("clear_secret", "on"), ], ) .await; assert_eq!(status, 303); let mirror = store.mirror(&id).await.expect("query").expect("mirror"); assert!(!mirror.has_secret); assert_eq!( mirror.url, "https://example.com/remote.git?token=abc123", "a blank URL keeps the stored one" ); } #[tokio::test] async fn delete_needs_the_confirm_box() { let dir = tempfile::tempdir().expect("tempdir"); let (store, app, repo) = fixture(dir.path()).await; let id = push_mirror(&store, &repo, None).await; let cookie = cookie_for("alice-token"); let uri = format!("/alice/proj/settings/mirrors/{id}/delete"); let (status, _, _) = send(&app, "POST", &uri, &cookie, &[]).await; assert_eq!(status, 303); assert!( store.mirror(&id).await.expect("query").is_some(), "nothing deleted without the box" ); let (status, location, _) = send(&app, "POST", &uri, &cookie, &[("confirm", "on")]).await; assert_eq!(status, 303); assert!(location.unwrap_or_default().contains("notice=deleted")); assert!(store.mirror(&id).await.expect("query").is_none()); } /// Gives the bare repo at `bare` a commit on `main`, so it has refs and is no longer empty. fn add_commit(bare: &Path) { let run = |args: &[&str]| -> String { let out = std::process::Command::new("git") .arg(format!("--git-dir={}", bare.display())) .args(args) .env("GIT_AUTHOR_NAME", "t") .env("GIT_AUTHOR_EMAIL", "t@example.com") .env("GIT_COMMITTER_NAME", "t") .env("GIT_COMMITTER_EMAIL", "t@example.com") .output() .expect("git runs"); assert!(out.status.success(), "git {args:?}: {out:?}"); String::from_utf8_lossy(&out.stdout).trim().to_string() }; let tree = run(&["hash-object", "-t", "tree", "-w", "--stdin"]); let commit = run(&["commit-tree", &tree, "-m", "first"]); run(&["update-ref", "refs/heads/main", &commit]); } #[tokio::test] async fn pull_mirror_can_be_added_to_an_empty_repo() { let dir = tempfile::tempdir().expect("tempdir"); let (store, app, repo) = fixture(dir.path()).await; let cookie = cookie_for("alice-token"); let (status, _, body) = send(&app, "GET", "/alice/proj/settings/mirrors", &cookie, &[]).await; assert_eq!(status, 200); assert!( body.contains("Add pull mirror"), "form missing on an empty repo" ); let (status, location, _) = send( &app, "POST", "/alice/proj/settings/mirrors", &cookie, &[ ("direction", "pull"), ("url", REMOTE), ("username", "bot"), ("secret", "hunter2-secret"), ("interval", "60"), ], ) .await; assert_eq!(status, 303); assert!(location.unwrap_or_default().contains("notice=pull-created")); let mirror = store .pull_mirror(&repo.id) .await .expect("query") .expect("a pull mirror"); assert_eq!(mirror.direction, MirrorDirection::Pull); assert_eq!(mirror.url, REMOTE); assert!(mirror.has_secret); assert!( mirror.next_sync_at <= now_stamp(), "the first sync is due now" ); // Audited like the push mirror. let filter = forge_core::AuditFilter { action: Some(forge_core::AuditAction::MirrorCreated), ..Default::default() }; let audit = store.list_audit(&filter, None, 10).await.expect("audit"); assert_eq!(audit.len(), 1, "mirror_created audited once"); // Once a pull mirror exists the form is gone. let (_, _, body) = send(&app, "GET", "/alice/proj/settings/mirrors", &cookie, &[]).await; assert!(!body.contains("Add pull mirror"), "form still shown"); } #[tokio::test] async fn pull_mirror_is_refused_on_a_repo_with_refs() { let dir = tempfile::tempdir().expect("tempdir"); let (store, app, repo) = fixture(dir.path()).await; let bare = repo_path(dir.path(), &repo.id).expect("repo path"); add_commit(&bare); let cookie = cookie_for("alice-token"); let (_, _, body) = send(&app, "GET", "/alice/proj/settings/mirrors", &cookie, &[]).await; assert!( !body.contains("Add pull mirror"), "form shown on a non-empty repo" ); let (status, location, _) = send( &app, "POST", "/alice/proj/settings/mirrors", &cookie, &[("direction", "pull"), ("url", REMOTE), ("interval", "60")], ) .await; assert_eq!(status, 303); assert!( location .unwrap_or_default() .contains("notice=pull-not-empty") ); assert!(store.pull_mirror(&repo.id).await.expect("query").is_none()); assert!(store.list_mirrors(&repo.id).await.expect("list").is_empty()); } #[tokio::test] async fn pull_mirror_add_is_404_for_non_admins() { let dir = tempfile::tempdir().expect("tempdir"); let (store, app, repo) = fixture(dir.path()).await; for token in ["bob-token", "carol-token"] { let (status, _, _) = send( &app, "POST", "/alice/proj/settings/mirrors", &cookie_for(token), &[("direction", "pull"), ("url", REMOTE), ("interval", "60")], ) .await; assert_eq!(status, 404, "pull add as {token}"); } assert!(store.list_mirrors(&repo.id).await.expect("list").is_empty()); } }