forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
52.4 KiB
1//! Page: repo mirrors (`/{owner}/{repo}/settings/mirrors`). Admins of the repo list its pull and push
2//! mirrors, add push mirrors, edit any mirror, sync one now and delete one. Anyone else gets a 404, so
3//! the page does not reveal that a repo mirrors anything. A mirror's password is sealed with the
4//! instance key and is write-only: it is never rendered, a blank password on edit keeps it, and the
5//! "clear" box removes it. URLs are validated with the webhook policy before they are saved and are
6//! shown redacted (host and path only) everywhere.
7
8use forge_core::AuditAction;
9use forge_core::mirrors::{
10 DEFAULT_INTERVAL_MINUTES, MIN_INTERVAL_MINUTES, Mirror, MirrorDirection, NewMirror,
11 SecretUpdate,
12};
13use forge_core::time::now_stamp;
14use forge_core::tz::format_in_tz;
15use serde::Deserialize;
16use time::OffsetDateTime;
17use time::format_description::well_known::Rfc3339;
18use topcoat::Result;
19use topcoat::context::{Cx, app_context};
20use topcoat::router::content::Form;
21use topcoat::router::error::{SeeOther, forbidden, not_found, see_other};
22use topcoat::router::request::uri;
23use topcoat::router::{page, path_param, route};
24use topcoat::view::{View, component, view};
25
26use crate::audit::{self, Actor, Target};
27use crate::auth::{require_csrf, require_user};
28use crate::i18n::{
29 self, Lang,
30 integrations::{self as g, K},
31};
32use crate::pages::refs::load_refs;
33use crate::pages::repo::repo_head;
34use crate::pages::repo_ctx::{RepoCtx, RepoHead, load_repo};
35use crate::pages::repo_settings::settings_nav_for;
36use crate::state::AppState;
37use crate::views::{alert, enc, shell, user_facing_message};
38use crate::webhooks::client::{WebhookPolicy, validate_url};
39
40path_param!(owner);
41path_param!(repo);
42path_param!(mirror_id);
43
44/// Longest username accepted, in characters.
45const MAX_USERNAME_CHARS: usize = 255;
46/// Longest password or token accepted, in characters.
47const MAX_SECRET_CHARS: usize = 1024;
48/// Longest sync interval accepted: one year.
49const MAX_INTERVAL_MINUTES: u32 = 525_600;
50
51/// Notice words (the only text a `?notice=` query can show), their text key, and whether they are errors.
52const NOTICES: [(&str, K, bool); 7] = [
53 ("created", K::NoticeCreated, false),
54 ("saved", K::NoticeSaved, false),
55 ("synced", K::NoticeSynced, false),
56 ("deleted", K::NoticeDeleted, false),
57 ("pull-created", K::NoticePullCreated, false),
58 ("pull-not-empty", K::NoticePullNotEmpty, true),
59 ("confirm", K::NoticeConfirm, true),
60];
61
62/// The add form for a push mirror, or for a pull mirror when `direction` is `pull`.
63#[derive(Deserialize)]
64struct PushForm {
65 #[serde(default)]
66 csrf: String,
67 #[serde(default)]
68 direction: String,
69 #[serde(default)]
70 url: String,
71 #[serde(default)]
72 username: String,
73 #[serde(default)]
74 secret: String,
75 #[serde(default)]
76 interval: String,
77}
78
79/// The edit form. A blank `url` keeps the stored URL, a blank `secret` keeps the stored password.
80#[derive(Deserialize)]
81struct EditForm {
82 #[serde(default)]
83 csrf: String,
84 #[serde(default)]
85 url: String,
86 #[serde(default)]
87 username: String,
88 #[serde(default)]
89 secret: String,
90 #[serde(default)]
91 clear_secret: Option<String>,
92 #[serde(default)]
93 interval: String,
94 #[serde(default)]
95 enabled: Option<String>,
96}
97
98#[derive(Deserialize)]
99struct CsrfForm {
100 #[serde(default)]
101 csrf: String,
102}
103
104#[derive(Deserialize)]
105struct DeleteForm {
106 #[serde(default)]
107 csrf: String,
108 #[serde(default)]
109 confirm: Option<String>,
110}
111
112/// The values of a checked mirror form, ready for the store. The URL is the validated one.
113pub struct Checked {
114 pub url: String,
115 pub username: String,
116 pub secret: String,
117 pub interval: u32,
118}
119
120/// The webhook policy from config. A bad `[webhooks]` section is a server error, not a user error.
121fn policy(state: &AppState) -> Result<WebhookPolicy> {
122 Ok(WebhookPolicy::from_config(&state.config.webhooks).map_err(forge_core::Error::Config)?)
123}
124
125/// The URL as the UI may show it: host, port and path only. Userinfo, query and fragment often carry
126/// credentials or tokens, so they are dropped. An unreadable URL shows a fixed placeholder.
127pub fn redacted_url(raw: &str) -> String {
128 match reqwest::Url::parse(raw) {
129 Ok(url) => {
130 let host = url.host_str().unwrap_or_default();
131 let port = url.port().map(|p| format!(":{p}")).unwrap_or_default();
132 format!("{host}{port}{}", url.path())
133 }
134 Err(_) => "(unreadable URL)".to_owned(),
135 }
136}
137
138/// Reads a stored RFC 3339 stamp and shows it in the viewer's timezone. An unreadable stamp is shown
139/// as it is.
140pub fn stamp_in_tz(stamp: &str, tz: &str) -> String {
141 match OffsetDateTime::parse(stamp, &Rfc3339) {
142 Ok(at) => format_in_tz(at.unix_timestamp(), tz),
143 Err(_) => stamp.to_owned(),
144 }
145}
146
147/// Parses the interval field: whole minutes, `0` for manual only, otherwise at least the minimum.
148/// A blank field takes the default.
149pub fn parse_interval(lang: Lang, raw: &str) -> std::result::Result<u32, String> {
150 let raw = raw.trim();
151 if raw.is_empty() {
152 return Ok(DEFAULT_INTERVAL_MINUTES);
153 }
154 let minutes: u32 = raw
155 .parse()
156 .map_err(|_| g::t(lang, K::ErrIntervalNotWhole).to_owned())?;
157 if minutes != 0 && minutes < MIN_INTERVAL_MINUTES {
158 return Err(g::interval_too_short(lang, MIN_INTERVAL_MINUTES));
159 }
160 if minutes > MAX_INTERVAL_MINUTES {
161 return Err(g::t(lang, K::ErrIntervalMax).to_owned());
162 }
163 Ok(minutes)
164}
165
166/// Checks a mirror form, with its messages in `lang`. The URL goes through the webhook policy
167/// (https only, no userinfo, no loopback or private addresses unless the instance allows them).
168/// The message is shown to the user, so it names the field and never echoes the URL.
169pub fn check_form_in(
170 lang: Lang,
171 state: &AppState,
172 url: &str,
173 username: &str,
174 secret: &str,
175 interval: &str,
176) -> std::result::Result<Checked, String> {
177 let url = url.trim();
178 if url.is_empty() {
179 return Err(g::t(lang, K::ErrUrlRequired).to_owned());
180 }
181 let policy = policy(state).map_err(|e| e.to_string())?;
182 validate_url(url, &policy).map_err(|e| e.to_string())?;
183 let username = username.trim();
184 if username.chars().count() > MAX_USERNAME_CHARS || username.contains(':') {
185 return Err(g::username_too_long(lang, MAX_USERNAME_CHARS));
186 }
187 let secret = secret.trim();
188 if secret.chars().count() > MAX_SECRET_CHARS {
189 return Err(g::password_too_long(lang, MAX_SECRET_CHARS));
190 }
191 Ok(Checked {
192 url: url.to_owned(),
193 username: username.to_owned(),
194 secret: secret.to_owned(),
195 interval: parse_interval(lang, interval)?,
196 })
197}
198
199/// The viewer's language for a repo page.
200fn lang_of(cx: &Cx, ctx: &RepoCtx) -> Lang {
201 i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user))
202}
203
204fn title_of(ctx: &RepoCtx) -> String {
205 format!("{}/{}", ctx.repo.owner_name, ctx.repo.name)
206}
207
208fn mirrors_href(title: &str) -> String {
209 format!("/{title}/settings/mirrors")
210}
211
212fn mirror_href(title: &str, id: &str) -> String {
213 format!("{}/{}", mirrors_href(title), enc(id))
214}
215
216/// The notice from `?notice=<word>`. Anything else is ignored.
217fn notice_from_query(cx: &Cx, lang: Lang) -> Option<(&'static str, bool)> {
218 let query = uri(cx).query()?;
219 let word = form_urlencoded::parse(query.as_bytes())
220 .find(|(key, _)| key == "notice")
221 .map(|(_, value)| value.into_owned())?;
222 NOTICES
223 .iter()
224 .find(|(name, _, _)| *name == word)
225 .map(|(_, key, is_error)| (g::t(lang, *key), *is_error))
226}
227
228fn back_to_list(title: &str, notice: &str) -> SeeOther {
229 see_other(format!("{}?notice={notice}", mirrors_href(title)))
230}
231
232fn back_to_mirror(title: &str, id: &str, notice: &str) -> SeeOther {
233 see_other(format!("{}?notice={notice}", mirror_href(title, id)))
234}
235
236fn direction_label(lang: Lang, direction: MirrorDirection) -> &'static str {
237 match direction {
238 MirrorDirection::Pull => g::t(lang, K::DirectionPull),
239 MirrorDirection::Push => g::t(lang, K::DirectionPush),
240 }
241}
242
243/// "Pull mirror" or "Push mirror", the heading of an edit page.
244fn mirror_kind(lang: Lang, direction: MirrorDirection) -> &'static str {
245 match direction {
246 MirrorDirection::Pull => g::t(lang, K::PullMirror),
247 MirrorDirection::Push => g::t(lang, K::PushMirror),
248 }
249}
250
251/// "every 8 h", "every 90 min" or "manual only".
252fn interval_text(lang: Lang, minutes: u32) -> String {
253 match minutes {
254 0 => g::t(lang, K::ManualOnly).to_owned(),
255 m if m % 60 == 0 => g::every_hours(lang, m / 60),
256 m => g::every_minutes(lang, m),
257 }
258}
259
260/// The last sync, as the list and the header show it: ok, failed (with the error) or not run yet.
261fn status_of(lang: Lang, mirror: &Mirror) -> (&'static str, &'static str) {
262 match mirror.last_ok {
263 Some(true) => ("ok", g::t(lang, K::StatusOk)),
264 Some(false) => ("failed", g::t(lang, K::StatusFailed)),
265 None => ("none", g::t(lang, K::StatusNotRun)),
266 }
267}
268
269/// The repo fields the mirror pages show, read once from the repo context.
270struct Frame {
271 page_title: String,
272 title: String,
273 archived: bool,
274 tz: String,
275 lang: Lang,
276 head: RepoHead,
277}
278
279impl Frame {
280 async fn new(ctx: &RepoCtx, state: &AppState, lang: Lang) -> Result<Self> {
281 let title = title_of(ctx);
282 Ok(Self {
283 page_title: format!("{title} \u{b7} {}", g::t(lang, K::Mirrors)),
284 title,
285 archived: ctx.repo.archived,
286 tz: ctx.viewer_tz().to_owned(),
287 lang,
288 head: ctx.head(state, "settings").await?,
289 })
290 }
291}
292
293/// The values an add form shows, plus its error.
294struct PushDraft {
295 url: String,
296 username: String,
297 interval: String,
298 error: Option<String>,
299}
300
301impl PushDraft {
302 fn blank() -> Self {
303 Self {
304 url: String::new(),
305 username: String::new(),
306 interval: DEFAULT_INTERVAL_MINUTES.to_string(),
307 error: None,
308 }
309 }
310}
311
312/// One mirror row on the list page, formatted for display.
313struct MirrorRow {
314 href: String,
315 sync_action: String,
316 direction: &'static str,
317 url: String,
318 interval: String,
319 enabled: bool,
320 last_sync: String,
321 status_class: &'static str,
322 status_text: &'static str,
323 error: String,
324}
325
326fn mirror_row(lang: Lang, mirror: &Mirror, title: &str, tz: &str) -> MirrorRow {
327 let (status_class, status_text) = status_of(lang, mirror);
328 MirrorRow {
329 href: mirror_href(title, &mirror.id),
330 sync_action: format!("{}/sync", mirror_href(title, &mirror.id)),
331 direction: direction_label(lang, mirror.direction),
332 url: redacted_url(&mirror.url),
333 interval: interval_text(lang, mirror.interval_minutes),
334 enabled: mirror.enabled,
335 last_sync: mirror
336 .last_sync_at
337 .as_deref()
338 .map(|stamp| stamp_in_tz(stamp, tz))
339 .unwrap_or_default(),
340 status_class,
341 status_text,
342 error: mirror.last_error.clone().unwrap_or_default(),
343 }
344}
345
346/// The list rows for the repo's mirrors.
347async fn rows_of(state: &AppState, ctx: &RepoCtx, frame: &Frame) -> Result<Vec<MirrorRow>> {
348 Ok(state
349 .store
350 .list_mirrors(&ctx.repo.id)
351 .await?
352 .iter()
353 .map(|mirror| mirror_row(frame.lang, mirror, &frame.title, &frame.tz))
354 .collect())
355}
356
357/// Header, sub-navigation and the one-line notice.
358#[component]
359async fn frame_top(
360 head: RepoHead,
361 title: String,
362 notice: Option<(&'static str, bool)>,
363 archived: bool,
364 lang: Lang,
365) -> Result<impl View> {
366 Ok(view! {
367 repo_head(head: head)
368 settings_nav_for(lang: lang, title: title, current: "mirrors")
369 if let Some((text, is_error)) = notice {
370 if is_error {
371 alert(message: text)
372 } else {
373 <section class="card notice" role="status">(text)</section>
374 }
375 }
376 if archived {
377 <section class="card notice" role="status">(g::t(lang, K::ArchivedNotice))</section>
378 }
379 })
380}
381
382/// The fields shared by the add and edit forms. `current_url` is the redacted stored URL on edit.
383/// `direction` is sent as a hidden field by the add-pull form only (empty for the others).
384#[component]
385async fn mirror_form(
386 action: String,
387 csrf: String,
388 pull: bool,
389 current_url: String,
390 url: String,
391 username: String,
392 has_secret: bool,
393 interval: String,
394 enabled: Option<bool>,
395 error: Option<String>,
396 submit: &'static str,
397 direction: &'static str,
398 lang: Lang,
399) -> Result<impl View> {
400 let url_label = if pull {
401 g::t(lang, K::RepositoryUrl)
402 } else {
403 g::t(lang, K::RemoteUrl)
404 };
405 let url_hint = if current_url.is_empty() {
406 g::t(lang, K::UrlHintNew)
407 } else {
408 g::t(lang, K::UrlHintKeep)
409 };
410 let secret_hint = if has_secret {
411 g::t(lang, K::SecretSet)
412 } else {
413 g::t(lang, K::SecretUnset)
414 };
415 Ok(view! {
416 <form method="post" action=(action.as_str()) class="stack mirror-form">
417 if let Some(message) = error {
418 alert(message: message.as_str())
419 }
420 <input type="hidden" name="csrf" value=(csrf.as_str())>
421 if !direction.is_empty() {
422 <input type="hidden" name="direction" value=(direction)>
423 }
424 if !current_url.is_empty() {
425 <p>(g::t(lang, K::CurrentUrl))" "<code>(current_url.as_str())</code></p>
426 }
427 <label>
428 <span>(url_label)</span>
429 <input type="url" name="url" value=(url.as_str()) autocomplete="off" spellcheck="false" required=(current_url.is_empty())>
430 </label>
431 <p class="muted small">(url_hint)</p>
432 <label>
433 <span>(g::t(lang, K::UsernameOptional))</span>
434 <input type="text" name="username" value=(username.as_str()) autocomplete="off" maxlength="255">
435 </label>
436 <label>
437 <span>(g::t(lang, K::SecretOptional))</span>
438 <input type="password" name="secret" autocomplete="new-password" maxlength="1024">
439 </label>
440 <p class="muted small">(secret_hint)</p>
441 if has_secret {
442 <label class="inline">
443 <input type="checkbox" name="clear_secret" value="on">
444 <span>(g::t(lang, K::ClearSecret))</span>
445 </label>
446 }
447 <label>
448 <span>(g::t(lang, K::IntervalField))</span>
449 <input type="number" name="interval" value=(interval.as_str()) min="0" max="525600" step="1">
450 </label>
451 if let Some(on) = enabled {
452 <label class="inline">
453 <input type="checkbox" name="enabled" value="on" checked=(on)>
454 <span>(g::t(lang, K::EnabledField))</span>
455 </label>
456 }
457 <div>
458 <button type="submit" class="primary">(submit)</button>
459 </div>
460 </form>
461 })
462}
463
464#[component]
465async fn mirrors_list_body(
466 head: RepoHead,
467 title: String,
468 notice: Option<(&'static str, bool)>,
469 archived: bool,
470 csrf: String,
471 rows: Vec<MirrorRow>,
472 draft: PushDraft,
473 pull_form: Option<PushDraft>,
474 lang: Lang,
475) -> Result<impl View> {
476 let add_action = mirrors_href(&title);
477 Ok(view! {
478 frame_top(head: head, title: title.clone(), notice: notice, archived: archived, lang: lang)
479 <section class="card">
480 <h2>(g::t(lang, K::Mirrors))</h2>
481 <p class="muted small">(g::t(lang, K::MirrorsIntro))</p>
482 if rows.is_empty() {
483 <p class="muted">(g::t(lang, K::NoMirrors))</p>
484 } else {
485 <div class="table-wrap">
486 <table class="table mirrors">
487 <thead>
488 <tr>
489 <th>(g::t(lang, K::ColDirection))</th>
490 <th>"URL"</th>
491 <th>(g::t(lang, K::ColInterval))</th>
492 <th>(g::t(lang, K::ColEnabled))</th>
493 <th>(g::t(lang, K::ColLastSync))</th>
494 <th>(g::t(lang, K::ColResult))</th>
495 <th></th>
496 </tr>
497 </thead>
498 <tbody>
499 for row in rows.iter() {
500 <tr>
501 <td>(row.direction)</td>
502 <td><a href=(row.href.as_str())><code>(row.url.as_str())</code></a></td>
503 <td>(row.interval.as_str())</td>
504 <td>(if row.enabled { g::t(lang, K::Yes) } else { g::t(lang, K::No) })</td>
505 <td>(row.last_sync.as_str())</td>
506 <td>
507 <span class=(format!("hook-state {}", row.status_class))>(row.status_text)</span>
508 if !row.error.is_empty() {
509 <details>
510 <summary>(g::t(lang, K::ErrorLabel))</summary>
511 <pre class="mirror-error">(row.error.as_str())</pre>
512 </details>
513 }
514 </td>
515 <td>
516 if !archived {
517 <form method="post" action=(row.sync_action.as_str()) class="inline-form">
518 <input type="hidden" name="csrf" value=(csrf.as_str())>
519 <button type="submit">(g::t(lang, K::SyncNow))</button>
520 </form>
521 }
522 </td>
523 </tr>
524 }
525 </tbody>
526 </table>
527 </div>
528 }
529 </section>
530 if !archived {
531 if let Some(pull) = pull_form {
532 <section class="card">
533 <h2>(g::t(lang, K::AddPullMirror))</h2>
534 <p class="muted small">(g::t(lang, K::AddPullIntro))</p>
535 mirror_form(action: add_action.clone(), csrf: csrf.clone(), pull: true, current_url: String::new(),
536 url: pull.url, username: pull.username, has_secret: false, interval: pull.interval,
537 enabled: None, error: pull.error, submit: g::t(lang, K::AddPullMirror), direction: "pull", lang: lang)
538 </section>
539 }
540 <section class="card">
541 <h2>(g::t(lang, K::AddPushMirror))</h2>
542 <p class="muted small">(g::t(lang, K::AddPushIntro))</p>
543 mirror_form(action: add_action.clone(), csrf: csrf.clone(), pull: false, current_url: String::new(),
544 url: draft.url, username: draft.username, has_secret: false, interval: draft.interval,
545 enabled: None, error: draft.error, submit: g::t(lang, K::AddPushMirror), direction: "", lang: lang)
546 </section>
547 }
548 })
549}
550
551/// Lists the repo's mirrors. Admins only: anyone else gets 404.
552#[page("/{owner}/{repo}/settings/mirrors")]
553async fn mirrors_list_page(cx: &Cx) -> Result<impl View> {
554 let owner = path_param::<Owner>(cx);
555 let name = path_param::<Repo>(cx);
556 let ctx = load_repo(cx, owner, name).await?;
557 let (_, session) = require_user(cx).await?;
558 if !ctx.can_admin() {
559 return Err(not_found().into());
560 }
561 let state = app_context::<AppState>(cx);
562 let lang = lang_of(cx, &ctx);
563 let frame = Frame::new(&ctx, state, lang).await?;
564 let rows = rows_of(state, &ctx, &frame).await?;
565 let csrf = session.csrf_token.clone();
566 let notice = notice_from_query(cx, lang);
567 let pull_form = can_add_pull(state, &ctx).await?.then(PushDraft::blank);
568 Ok(view! {
569 shell(title: frame.page_title.as_str(), viewer: ctx.viewer.as_ref(),
570 mirrors_list_body(head: frame.head, title: frame.title, notice: notice,
571 archived: frame.archived, csrf: csrf, rows: rows, draft: PushDraft::blank(),
572 pull_form: pull_form, lang: lang))
573 })
574}
575
576/// The list page's rows and frame, for a page built by a handler.
577async fn list_data(state: &AppState, ctx: &RepoCtx, lang: Lang) -> Result<(Frame, Vec<MirrorRow>)> {
578 let frame = Frame::new(ctx, state, lang).await?;
579 let rows = rows_of(state, ctx, &frame).await?;
580 Ok((frame, rows))
581}
582
583/// Whether the page offers the add-pull-mirror form: the repo has no pull mirror and no refs.
584async fn can_add_pull(state: &AppState, ctx: &RepoCtx) -> Result<bool> {
585 if state.store.pull_mirror(&ctx.repo.id).await?.is_some() {
586 return Ok(false);
587 }
588 Ok(load_refs(ctx.path.clone()).await?.is_empty())
589}
590
591/// Checks the form and stores a mirror of `direction`. `Ok(Err(message))` is a refusal the form
592/// shows. A new pull mirror is scheduled now and wakes the worker; a push mirror waits for its
593/// first push or interval, as before.
594async fn store_mirror(
595 lang: Lang,
596 state: &AppState,
597 ctx: &RepoCtx,
598 direction: MirrorDirection,
599 form: &PushForm,
600) -> Result<std::result::Result<Mirror, String>> {
601 let checked = match check_form_in(
602 lang,
603 state,
604 &form.url,
605 &form.username,
606 &form.secret,
607 &form.interval,
608 ) {
609 Ok(checked) => checked,
610 Err(message) => return Ok(Err(message)),
611 };
612 let secret = (!checked.secret.is_empty()).then_some(checked.secret.as_bytes());
613 let created = state
614 .store
615 .create_mirror(&NewMirror {
616 repo_id: &ctx.repo.id,
617 direction,
618 url: &checked.url,
619 username: &checked.username,
620 secret,
621 interval_minutes: checked.interval,
622 })
623 .await;
624 let mirror = match created {
625 Ok(mirror) => mirror,
626 Err(e) => match user_facing_message(&e) {
627 Some(message) => return Ok(Err(message)),
628 None => return Err(e.into()),
629 },
630 };
631 if direction == MirrorDirection::Pull {
632 state
633 .store
634 .schedule_mirror(&mirror.id, &now_stamp())
635 .await?;
636 state.mirror_waker.notify_one();
637 }
638 Ok(Ok(mirror))
639}
640
641/// Stores a push mirror.
642async fn add_push_mirror(
643 lang: Lang,
644 state: &AppState,
645 ctx: &RepoCtx,
646 form: &PushForm,
647) -> Result<std::result::Result<Mirror, String>> {
648 store_mirror(lang, state, ctx, MirrorDirection::Push, form).await
649}
650
651/// Stores the pull mirror of an empty repo. A repo with refs is refused with a notice.
652async fn add_pull_mirror(
653 lang: Lang,
654 state: &AppState,
655 ctx: &RepoCtx,
656 form: &PushForm,
657) -> Result<std::result::Result<Mirror, String>> {
658 if !load_refs(ctx.path.clone()).await?.is_empty() {
659 return Err(back_to_list(&title_of(ctx), "pull-not-empty").into());
660 }
661 store_mirror(lang, state, ctx, MirrorDirection::Pull, form).await
662}
663
664/// Adds a push mirror, or a pull mirror when the form says `direction=pull`. The URL is checked
665/// before anything is stored.
666#[page(POST "/{owner}/{repo}/settings/mirrors")]
667async fn mirror_create(cx: &Cx, Form(form): Form<PushForm>) -> Result<impl View> {
668 let owner = path_param::<Owner>(cx);
669 let name = path_param::<Repo>(cx);
670 let ctx = load_repo(cx, owner, name).await?;
671 let (user, session) = require_csrf(cx, &form.csrf).await?;
672 if !ctx.can_admin() {
673 return Err(not_found().into());
674 }
675 if ctx.repo.archived {
676 return Err(forbidden().into());
677 }
678 let state = app_context::<AppState>(cx);
679 let lang = lang_of(cx, &ctx);
680 let pull = form.direction == "pull";
681 let stored = if pull {
682 add_pull_mirror(lang, state, &ctx, &form).await?
683 } else {
684 add_push_mirror(lang, state, &ctx, &form).await?
685 };
686 let mirror = match stored {
687 Ok(mirror) => mirror,
688 Err(message) => {
689 let (frame, rows) = list_data(state, &ctx, lang).await?;
690 let csrf = session.csrf_token.clone();
691 let typed = PushDraft {
692 url: form.url.trim().to_owned(),
693 username: form.username.trim().to_owned(),
694 interval: form.interval.trim().to_owned(),
695 error: Some(message),
696 };
697 let (draft, pull_form) = if pull {
698 (PushDraft::blank(), Some(typed))
699 } else {
700 (
701 typed,
702 can_add_pull(state, &ctx).await?.then(PushDraft::blank),
703 )
704 };
705 return Ok(view! {
706 shell(title: frame.page_title.as_str(), viewer: ctx.viewer.as_ref(),
707 mirrors_list_body(head: frame.head, title: frame.title, notice: None,
708 archived: frame.archived, csrf: csrf, rows: rows, draft: draft,
709 pull_form: pull_form, lang: lang))
710 });
711 }
712 };
713 audit::record(
714 &state.store,
715 Some(cx),
716 Actor::User(&user),
717 AuditAction::MirrorCreated,
718 Target {
719 kind: "repo",
720 id: &ctx.repo.id,
721 name: &title_of(&ctx),
722 },
723 serde_json::json!({
724 "mirror_id": mirror.id,
725 "direction": mirror.direction.as_str(),
726 "url": redacted_url(&mirror.url),
727 }),
728 )
729 .await;
730 tracing::info!(actor_id = %user.id, repo_id = %ctx.repo.id, mirror_id = %mirror.id, direction = %mirror.direction.as_str(), "mirror created");
731 let notice = if pull { "pull-created" } else { "created" };
732 Err(back_to_list(&title_of(&ctx), notice).into())
733}
734
735/// The mirror in the path, when it belongs to this repo. Any other mirror answers 404.
736async fn repo_mirror(state: &AppState, ctx: &RepoCtx, id: &str) -> Result<Mirror> {
737 match state.store.mirror(id).await? {
738 Some(mirror) if mirror.repo_id == ctx.repo.id => Ok(mirror),
739 _ => Err(not_found().into()),
740 }
741}
742
743/// The edit page body: the form, the sync and delete controls.
744#[component]
745async fn mirror_body(
746 head: RepoHead,
747 title: String,
748 notice: Option<(&'static str, bool)>,
749 archived: bool,
750 csrf: String,
751 mirror_id: String,
752 mirror: Mirror,
753 draft: EditDraft,
754 lang: Lang,
755) -> Result<impl View> {
756 let edit_action = mirror_href(&title, &mirror_id);
757 let sync_action = format!("{edit_action}/sync");
758 let delete_action = format!("{edit_action}/delete");
759 let pull = mirror.direction == MirrorDirection::Pull;
760 let last_sync = mirror
761 .last_sync_at
762 .as_deref()
763 .map(|stamp| stamp_in_tz(stamp, &draft.tz))
764 .unwrap_or_else(|| g::t(lang, K::Never).to_owned());
765 let delete_text = if pull {
766 g::t(lang, K::DeleteTextPull)
767 } else {
768 g::t(lang, K::DeleteTextPush)
769 };
770 let current_url = redacted_url(&mirror.url);
771 Ok(view! {
772 frame_top(head: head, title: title.clone(), notice: notice, archived: archived, lang: lang)
773 <section class="card">
774 <h2>(mirror_kind(lang, mirror.direction))</h2>
775 if archived {
776 <p><code>(current_url.as_str())</code></p>
777 <p class="muted small">(g::t(lang, K::LastSync))" " (last_sync.as_str())</p>
778 if let Some(error) = mirror.last_error.clone() {
779 <pre class="mirror-error">(error.as_str())</pre>
780 }
781 } else {
782 mirror_form(action: edit_action.clone(), csrf: csrf.clone(), pull: pull,
783 current_url: current_url.clone(), url: String::new(), username: draft.username,
784 has_secret: mirror.has_secret, interval: draft.interval, enabled: Some(draft.enabled),
785 error: draft.error, submit: g::t(lang, K::SaveMirror), direction: "", lang: lang)
786 }
787 </section>
788 if !archived {
789 <section class="card">
790 <h2>(g::t(lang, K::SyncHeading))</h2>
791 <p class="muted small">(g::t(lang, K::SyncIntro))</p>
792 <form method="post" action=(sync_action.as_str()) class="inline-form">
793 <input type="hidden" name="csrf" value=(csrf.as_str())>
794 <button type="submit">(g::t(lang, K::SyncNow))</button>
795 </form>
796 </section>
797 <section class="card">
798 <h2>(g::t(lang, K::DeleteMirror))</h2>
799 <p class="muted small">(delete_text)</p>
800 <form method="post" action=(delete_action.as_str()) class="stack">
801 <input type="hidden" name="csrf" value=(csrf.as_str())>
802 <label class="inline">
803 <input type="checkbox" name="confirm" value="on">
804 <span>(g::t(lang, K::DeleteConfirm))</span>
805 </label>
806 <div>
807 <button type="submit" class="danger">(g::t(lang, K::DeleteMirror))</button>
808 </div>
809 </form>
810 </section>
811 }
812 })
813}
814
815/// The edit form's values: the username and the interval as stored, or as submitted on error.
816struct EditDraft {
817 tz: String,
818 username: String,
819 interval: String,
820 enabled: bool,
821 error: Option<String>,
822}
823
824impl EditDraft {
825 fn of_mirror(mirror: &Mirror, tz: &str) -> Self {
826 Self {
827 tz: tz.to_owned(),
828 username: mirror.username.clone(),
829 interval: mirror.interval_minutes.to_string(),
830 enabled: mirror.enabled,
831 error: None,
832 }
833 }
834}
835
836/// The edit page for one mirror.
837#[page("/{owner}/{repo}/settings/mirrors/{mirror_id}")]
838async fn mirror_page(cx: &Cx) -> Result<impl View> {
839 let owner = path_param::<Owner>(cx);
840 let name = path_param::<Repo>(cx);
841 let id = path_param::<MirrorId>(cx);
842 let ctx = load_repo(cx, owner, name).await?;
843 let (_, session) = require_user(cx).await?;
844 if !ctx.can_admin() {
845 return Err(not_found().into());
846 }
847 let state = app_context::<AppState>(cx);
848 let mirror = repo_mirror(state, &ctx, id).await?;
849 let lang = lang_of(cx, &ctx);
850 let frame = Frame::new(&ctx, state, lang).await?;
851 let draft = EditDraft::of_mirror(&mirror, &frame.tz);
852 let csrf = session.csrf_token.clone();
853 let notice = notice_from_query(cx, lang);
854 let mirror_id = mirror.id.clone();
855 Ok(view! {
856 shell(title: frame.page_title.as_str(), viewer: ctx.viewer.as_ref(),
857 mirror_body(head: frame.head, title: frame.title, notice: notice,
858 archived: frame.archived, csrf: csrf, mirror_id: mirror_id, mirror: mirror,
859 draft: draft, lang: lang))
860 })
861}
862
863/// Saves a mirror. A blank URL keeps the stored one, a blank password keeps the stored password and
864/// the clear box removes it. A refused form shows the error on the edit page.
865#[page(POST "/{owner}/{repo}/settings/mirrors/{mirror_id}")]
866async fn mirror_update(cx: &Cx, Form(form): Form<EditForm>) -> Result<impl View> {
867 let owner = path_param::<Owner>(cx);
868 let name = path_param::<Repo>(cx);
869 let id = path_param::<MirrorId>(cx);
870 let ctx = load_repo(cx, owner, name).await?;
871 let (user, session) = require_csrf(cx, &form.csrf).await?;
872 if !ctx.can_admin() {
873 return Err(not_found().into());
874 }
875 if ctx.repo.archived {
876 return Err(forbidden().into());
877 }
878 let state = app_context::<AppState>(cx);
879 let mirror = repo_mirror(state, &ctx, id).await?;
880 let url = if form.url.trim().is_empty() {
881 mirror.url.clone()
882 } else {
883 form.url.trim().to_owned()
884 };
885 let lang = lang_of(cx, &ctx);
886 let checked = check_form_in(
887 lang,
888 state,
889 &url,
890 &form.username,
891 &form.secret,
892 &form.interval,
893 );
894 let checked = match checked {
895 Ok(checked) => checked,
896 Err(message) => {
897 let frame = Frame::new(&ctx, state, lang).await?;
898 let draft = EditDraft {
899 tz: frame.tz.clone(),
900 username: form.username.trim().to_owned(),
901 interval: form.interval.trim().to_owned(),
902 enabled: form.enabled.is_some(),
903 error: Some(message),
904 };
905 let csrf = session.csrf_token.clone();
906 let mirror_id = mirror.id.clone();
907 return Ok(view! {
908 shell(title: frame.page_title.as_str(), viewer: ctx.viewer.as_ref(),
909 mirror_body(head: frame.head, title: frame.title, notice: None,
910 archived: frame.archived, csrf: csrf, mirror_id: mirror_id, mirror: mirror,
911 draft: draft, lang: lang))
912 });
913 }
914 };
915 let secret_update = if !form.secret.trim().is_empty() {
916 SecretUpdate::Set(checked.secret.as_bytes())
917 } else if form.clear_secret.is_some() {
918 SecretUpdate::Clear
919 } else {
920 SecretUpdate::Keep
921 };
922 let cleared = matches!(secret_update, SecretUpdate::Clear);
923 state
924 .store
925 .update_mirror(
926 &mirror.id,
927 &checked.url,
928 &checked.username,
929 secret_update,
930 checked.interval,
931 form.enabled.is_some(),
932 )
933 .await?;
934 audit::record(
935 &state.store,
936 Some(cx),
937 Actor::User(&user),
938 AuditAction::MirrorUpdated,
939 Target {
940 kind: "repo",
941 id: &ctx.repo.id,
942 name: &title_of(&ctx),
943 },
944 serde_json::json!({
945 "mirror_id": mirror.id,
946 "direction": mirror.direction.as_str(),
947 "url": redacted_url(&checked.url),
948 "secret_changed": !matches!(secret_update, SecretUpdate::Keep),
949 "secret_cleared": cleared,
950 }),
951 )
952 .await;
953 tracing::info!(actor_id = %user.id, repo_id = %ctx.repo.id, mirror_id = %mirror.id, "mirror updated");
954 Err(back_to_mirror(&title_of(&ctx), &mirror.id, "saved").into())
955}
956
957/// Queues a sync now: the mirror is due at the current time and the worker is woken.
958#[route(POST "/{owner}/{repo}/settings/mirrors/{mirror_id}/sync")]
959async fn mirror_sync(cx: &Cx, Form(form): Form<CsrfForm>) -> Result<SeeOther> {
960 let owner = path_param::<Owner>(cx);
961 let name = path_param::<Repo>(cx);
962 let id = path_param::<MirrorId>(cx);
963 let ctx = load_repo(cx, owner, name).await?;
964 require_csrf(cx, &form.csrf).await?;
965 if !ctx.can_admin() {
966 return Err(not_found().into());
967 }
968 if ctx.repo.archived {
969 return Err(forbidden().into());
970 }
971 let state = app_context::<AppState>(cx);
972 let mirror = repo_mirror(state, &ctx, id).await?;
973 state
974 .store
975 .schedule_mirror(&mirror.id, &now_stamp())
976 .await?;
977 state.mirror_waker.notify_one();
978 Ok(back_to_list(&title_of(&ctx), "synced"))
979}
980
981/// Deletes a mirror. The box must be ticked. Deleting the pull mirror makes the repo a normal one.
982#[route(POST "/{owner}/{repo}/settings/mirrors/{mirror_id}/delete")]
983async fn mirror_delete(cx: &Cx, Form(form): Form<DeleteForm>) -> Result<SeeOther> {
984 let owner = path_param::<Owner>(cx);
985 let name = path_param::<Repo>(cx);
986 let id = path_param::<MirrorId>(cx);
987 let ctx = load_repo(cx, owner, name).await?;
988 let (user, _) = require_csrf(cx, &form.csrf).await?;
989 if !ctx.can_admin() {
990 return Err(not_found().into());
991 }
992 if ctx.repo.archived {
993 return Err(forbidden().into());
994 }
995 let state = app_context::<AppState>(cx);
996 let mirror = repo_mirror(state, &ctx, id).await?;
997 let title = title_of(&ctx);
998 if form.confirm.is_none() {
999 return Ok(back_to_mirror(&title, &mirror.id, "confirm"));
1000 }
1001 state.store.delete_mirror(&mirror.id).await?;
1002 audit::record(
1003 &state.store,
1004 Some(cx),
1005 Actor::User(&user),
1006 AuditAction::MirrorDeleted,
1007 Target {
1008 kind: "repo",
1009 id: &ctx.repo.id,
1010 name: &title,
1011 },
1012 serde_json::json!({
1013 "mirror_id": mirror.id,
1014 "direction": mirror.direction.as_str(),
1015 "url": redacted_url(&mirror.url),
1016 }),
1017 )
1018 .await;
1019 tracing::info!(actor_id = %user.id, repo_id = %ctx.repo.id, mirror_id = %mirror.id, "mirror deleted");
1020 Ok(back_to_list(&title, "deleted"))
1021}
1022
1023#[cfg(test)]
1024mod tests {
1025 use std::path::Path;
1026
1027 use forge_core::mirrors::{MirrorDirection, NewMirror};
1028 use forge_core::time::now_stamp;
1029 use forge_core::{Role, Store, Visibility};
1030 use forge_git::repo::{init_bare, repo_path};
1031 use topcoat::router::request::Request;
1032 use topcoat::router::{Body, Router, to_bytes};
1033
1034 use super::redacted_url;
1035 use crate::auth::SESSION_COOKIE;
1036 use crate::test_support::{app_router, create_session, test_state};
1037
1038 const CSRF: &str = "mirrors-test-csrf";
1039 const REMOTE: &str = "https://example.com/upstream/proj.git";
1040
1041 /// Alice owns `proj` (private), bob is a Write collaborator, carol is outside the repo.
1042 async fn fixture(dir: &Path) -> (Store, Router, forge_core::Repo) {
1043 let state = test_state(dir).await;
1044 let store = state.store.clone();
1045 let alice = store
1046 .create_user("alice", None, "unused", false)
1047 .await
1048 .expect("alice");
1049 let bob = store
1050 .create_user("bob", None, "unused", false)
1051 .await
1052 .expect("bob");
1053 store
1054 .create_user("carol", None, "unused", false)
1055 .await
1056 .expect("carol");
1057 let repo = store
1058 .create_repo(&alice.id, "proj", "", Visibility::Private, "main")
1059 .await
1060 .expect("repo");
1061 let bare = repo_path(&state.git.data_dir, &repo.id).expect("repo path");
1062 init_bare(&state.git, &bare, "main")
1063 .await
1064 .expect("init bare");
1065 store
1066 .set_collaborator(&repo.id, &bob.id, Role::Write)
1067 .await
1068 .expect("collaborator");
1069 create_session(&store, &alice.id, "alice-token", CSRF).await;
1070 create_session(&store, &bob.id, "bob-token", CSRF).await;
1071 let carol = store
1072 .user_by_username("carol")
1073 .await
1074 .expect("lookup")
1075 .expect("carol");
1076 create_session(&store, &carol.id, "carol-token", CSRF).await;
1077 (store, app_router(state), repo)
1078 }
1079
1080 fn cookie_for(token: &str) -> String {
1081 format!("__Host-{SESSION_COOKIE}={token}")
1082 }
1083
1084 /// Sends a request. A POST carries the CSRF token and the given fields as a form body.
1085 /// Returns the status, the Location header (if any) and the body.
1086 async fn send(
1087 app: &Router,
1088 method: &str,
1089 uri: &str,
1090 cookie: &str,
1091 fields: &[(&str, &str)],
1092 ) -> (u16, Option<String>, String) {
1093 let mut form = form_urlencoded::Serializer::new(String::new());
1094 if method == "POST" {
1095 form.append_pair("csrf", CSRF);
1096 }
1097 for (name, value) in fields {
1098 form.append_pair(name, value);
1099 }
1100 let mut builder = Request::builder()
1101 .method(method)
1102 .uri(uri)
1103 .header("cookie", cookie);
1104 if method == "POST" {
1105 builder = builder.header("content-type", "application/x-www-form-urlencoded");
1106 }
1107 let req = builder.body(Body::from(form.finish())).expect("request");
1108 let res = app.handle(req).await;
1109 let status = res.status().as_u16();
1110 let location = res
1111 .headers()
1112 .get("location")
1113 .and_then(|value| value.to_str().ok())
1114 .map(str::to_owned);
1115 let body = to_bytes(res.into_body(), usize::MAX).await.expect("body");
1116 (
1117 status,
1118 location,
1119 String::from_utf8_lossy(&body).into_owned(),
1120 )
1121 }
1122
1123 async fn push_mirror(store: &Store, repo: &forge_core::Repo, secret: Option<&[u8]>) -> String {
1124 store
1125 .create_mirror(&NewMirror {
1126 repo_id: &repo.id,
1127 direction: MirrorDirection::Push,
1128 url: "https://example.com/remote.git?token=abc123",
1129 username: "bot",
1130 secret,
1131 interval_minutes: 480,
1132 })
1133 .await
1134 .expect("mirror")
1135 .id
1136 }
1137
1138 #[test]
1139 fn redacted_url_keeps_host_port_and_path_only() {
1140 assert_eq!(
1141 redacted_url("https://example.com:8443/a/b.git?token=abc#frag"),
1142 "example.com:8443/a/b.git"
1143 );
1144 assert_eq!(redacted_url(REMOTE), "example.com/upstream/proj.git");
1145 assert_eq!(redacted_url("not a url"), "(unreadable URL)");
1146 }
1147
1148 #[tokio::test]
1149 async fn non_admins_get_not_found() {
1150 let dir = tempfile::tempdir().expect("tempdir");
1151 let (store, app, repo) = fixture(dir.path()).await;
1152 let id = push_mirror(&store, &repo, None).await;
1153 for token in ["bob-token", "carol-token"] {
1154 let (status, _, _) = send(
1155 &app,
1156 "GET",
1157 "/alice/proj/settings/mirrors",
1158 &cookie_for(token),
1159 &[],
1160 )
1161 .await;
1162 assert_eq!(status, 404, "list as {token}");
1163 let (status, _, _) = send(
1164 &app,
1165 "GET",
1166 &format!("/alice/proj/settings/mirrors/{id}"),
1167 &cookie_for(token),
1168 &[],
1169 )
1170 .await;
1171 assert_eq!(status, 404, "edit as {token}");
1172 let (status, _, _) = send(
1173 &app,
1174 "POST",
1175 &format!("/alice/proj/settings/mirrors/{id}/sync"),
1176 &cookie_for(token),
1177 &[],
1178 )
1179 .await;
1180 assert_eq!(status, 404, "sync as {token}");
1181 }
1182 let (status, _, _) = send(
1183 &app,
1184 "GET",
1185 "/alice/proj/settings/mirrors",
1186 &cookie_for("alice-token"),
1187 &[],
1188 )
1189 .await;
1190 assert_eq!(status, 200);
1191 }
1192
1193 #[tokio::test]
1194 async fn list_page_follows_the_stored_turkish_language() {
1195 let dir = tempfile::tempdir().expect("tempdir");
1196 let (store, app, _repo) = fixture(dir.path()).await;
1197 let alice = store
1198 .user_by_username("alice")
1199 .await
1200 .expect("lookup")
1201 .expect("alice");
1202 store
1203 .set_ui_prefs(&alice.id, "auto", "tr")
1204 .await
1205 .expect("set language");
1206 let (status, _, body) = send(
1207 &app,
1208 "GET",
1209 "/alice/proj/settings/mirrors",
1210 &cookie_for("alice-token"),
1211 &[],
1212 )
1213 .await;
1214 assert_eq!(status, 200, "{body}");
1215 assert!(
1216 body.contains("<h2>Yansılar</h2>"),
1217 "Turkish heading missing"
1218 );
1219 }
1220
1221 #[tokio::test]
1222 async fn secret_is_never_rendered_and_urls_are_redacted() {
1223 let dir = tempfile::tempdir().expect("tempdir");
1224 let (store, app, repo) = fixture(dir.path()).await;
1225 let id = push_mirror(&store, &repo, Some(b"hunter2-secret")).await;
1226 let cookie = cookie_for("alice-token");
1227 for uri in [
1228 "/alice/proj/settings/mirrors".to_owned(),
1229 format!("/alice/proj/settings/mirrors/{id}"),
1230 ] {
1231 let (status, _, body) = send(&app, "GET", &uri, &cookie, &[]).await;
1232 assert_eq!(status, 200, "{uri}");
1233 assert!(!body.contains("hunter2-secret"), "secret rendered on {uri}");
1234 assert!(!body.contains("abc123"), "query string rendered on {uri}");
1235 assert!(
1236 body.contains("example.com/remote.git"),
1237 "redacted URL missing on {uri}"
1238 );
1239 }
1240 assert!(
1241 store
1242 .mirror(&id)
1243 .await
1244 .expect("query")
1245 .expect("mirror")
1246 .has_secret
1247 );
1248 }
1249
1250 #[tokio::test]
1251 async fn loopback_url_is_rejected_by_the_form() {
1252 let dir = tempfile::tempdir().expect("tempdir");
1253 let (store, app, repo) = fixture(dir.path()).await;
1254 let (status, _, body) = send(
1255 &app,
1256 "POST",
1257 "/alice/proj/settings/mirrors",
1258 &cookie_for("alice-token"),
1259 &[("url", "https://127.0.0.1/remote.git"), ("interval", "480")],
1260 )
1261 .await;
1262 assert_eq!(status, 200);
1263 assert!(body.contains("destination not allowed"), "error not shown");
1264 assert!(store.list_mirrors(&repo.id).await.expect("list").is_empty());
1265 }
1266
1267 #[tokio::test]
1268 async fn add_push_mirror_then_sync_now_schedules_it() {
1269 let dir = tempfile::tempdir().expect("tempdir");
1270 let (store, app, repo) = fixture(dir.path()).await;
1271 let cookie = cookie_for("alice-token");
1272 let (status, location, _) = send(
1273 &app,
1274 "POST",
1275 "/alice/proj/settings/mirrors",
1276 &cookie,
1277 &[
1278 ("url", REMOTE),
1279 ("username", "bot"),
1280 ("secret", "hunter2-secret"),
1281 ("interval", "60"),
1282 ],
1283 )
1284 .await;
1285 assert_eq!(status, 303);
1286 assert!(location.unwrap_or_default().contains("notice=created"));
1287 let mirrors = store.list_mirrors(&repo.id).await.expect("list");
1288 assert_eq!(mirrors.len(), 1);
1289 let mirror = &mirrors[0];
1290 assert_eq!(mirror.direction, MirrorDirection::Push);
1291 assert_eq!(mirror.url, REMOTE);
1292 assert!(mirror.has_secret);
1293
1294 let (status, _, _) = send(
1295 &app,
1296 "POST",
1297 &format!("/alice/proj/settings/mirrors/{}/sync", mirror.id),
1298 &cookie,
1299 &[],
1300 )
1301 .await;
1302 assert_eq!(status, 303);
1303 let synced = store
1304 .mirror(&mirror.id)
1305 .await
1306 .expect("query")
1307 .expect("mirror");
1308 assert!(
1309 synced.next_sync_at <= now_stamp(),
1310 "sync now makes the mirror due"
1311 );
1312 }
1313
1314 #[tokio::test]
1315 async fn edit_clears_the_password_on_request() {
1316 let dir = tempfile::tempdir().expect("tempdir");
1317 let (store, app, repo) = fixture(dir.path()).await;
1318 let id = push_mirror(&store, &repo, Some(b"hunter2-secret")).await;
1319 let (status, _, _) = send(
1320 &app,
1321 "POST",
1322 &format!("/alice/proj/settings/mirrors/{id}"),
1323 &cookie_for("alice-token"),
1324 &[
1325 ("url", ""),
1326 ("username", "bot"),
1327 ("interval", "480"),
1328 ("enabled", "on"),
1329 ("clear_secret", "on"),
1330 ],
1331 )
1332 .await;
1333 assert_eq!(status, 303);
1334 let mirror = store.mirror(&id).await.expect("query").expect("mirror");
1335 assert!(!mirror.has_secret);
1336 assert_eq!(
1337 mirror.url, "https://example.com/remote.git?token=abc123",
1338 "a blank URL keeps the stored one"
1339 );
1340 }
1341
1342 #[tokio::test]
1343 async fn delete_needs_the_confirm_box() {
1344 let dir = tempfile::tempdir().expect("tempdir");
1345 let (store, app, repo) = fixture(dir.path()).await;
1346 let id = push_mirror(&store, &repo, None).await;
1347 let cookie = cookie_for("alice-token");
1348 let uri = format!("/alice/proj/settings/mirrors/{id}/delete");
1349 let (status, _, _) = send(&app, "POST", &uri, &cookie, &[]).await;
1350 assert_eq!(status, 303);
1351 assert!(
1352 store.mirror(&id).await.expect("query").is_some(),
1353 "nothing deleted without the box"
1354 );
1355 let (status, location, _) = send(&app, "POST", &uri, &cookie, &[("confirm", "on")]).await;
1356 assert_eq!(status, 303);
1357 assert!(location.unwrap_or_default().contains("notice=deleted"));
1358 assert!(store.mirror(&id).await.expect("query").is_none());
1359 }
1360
1361 /// Gives the bare repo at `bare` a commit on `main`, so it has refs and is no longer empty.
1362 fn add_commit(bare: &Path) {
1363 let run = |args: &[&str]| -> String {
1364 let out = std::process::Command::new("git")
1365 .arg(format!("--git-dir={}", bare.display()))
1366 .args(args)
1367 .env("GIT_AUTHOR_NAME", "t")
1368 .env("GIT_AUTHOR_EMAIL", "t@example.com")
1369 .env("GIT_COMMITTER_NAME", "t")
1370 .env("GIT_COMMITTER_EMAIL", "t@example.com")
1371 .output()
1372 .expect("git runs");
1373 assert!(out.status.success(), "git {args:?}: {out:?}");
1374 String::from_utf8_lossy(&out.stdout).trim().to_string()
1375 };
1376 let tree = run(&["hash-object", "-t", "tree", "-w", "--stdin"]);
1377 let commit = run(&["commit-tree", &tree, "-m", "first"]);
1378 run(&["update-ref", "refs/heads/main", &commit]);
1379 }
1380
1381 #[tokio::test]
1382 async fn pull_mirror_can_be_added_to_an_empty_repo() {
1383 let dir = tempfile::tempdir().expect("tempdir");
1384 let (store, app, repo) = fixture(dir.path()).await;
1385 let cookie = cookie_for("alice-token");
1386 let (status, _, body) =
1387 send(&app, "GET", "/alice/proj/settings/mirrors", &cookie, &[]).await;
1388 assert_eq!(status, 200);
1389 assert!(
1390 body.contains("Add pull mirror"),
1391 "form missing on an empty repo"
1392 );
1393
1394 let (status, location, _) = send(
1395 &app,
1396 "POST",
1397 "/alice/proj/settings/mirrors",
1398 &cookie,
1399 &[
1400 ("direction", "pull"),
1401 ("url", REMOTE),
1402 ("username", "bot"),
1403 ("secret", "hunter2-secret"),
1404 ("interval", "60"),
1405 ],
1406 )
1407 .await;
1408 assert_eq!(status, 303);
1409 assert!(location.unwrap_or_default().contains("notice=pull-created"));
1410 let mirror = store
1411 .pull_mirror(&repo.id)
1412 .await
1413 .expect("query")
1414 .expect("a pull mirror");
1415 assert_eq!(mirror.direction, MirrorDirection::Pull);
1416 assert_eq!(mirror.url, REMOTE);
1417 assert!(mirror.has_secret);
1418 assert!(
1419 mirror.next_sync_at <= now_stamp(),
1420 "the first sync is due now"
1421 );
1422
1423 // Audited like the push mirror.
1424 let filter = forge_core::AuditFilter {
1425 action: Some(forge_core::AuditAction::MirrorCreated),
1426 ..Default::default()
1427 };
1428 let audit = store.list_audit(&filter, None, 10).await.expect("audit");
1429 assert_eq!(audit.len(), 1, "mirror_created audited once");
1430
1431 // Once a pull mirror exists the form is gone.
1432 let (_, _, body) = send(&app, "GET", "/alice/proj/settings/mirrors", &cookie, &[]).await;
1433 assert!(!body.contains("Add pull mirror"), "form still shown");
1434 }
1435
1436 #[tokio::test]
1437 async fn pull_mirror_is_refused_on_a_repo_with_refs() {
1438 let dir = tempfile::tempdir().expect("tempdir");
1439 let (store, app, repo) = fixture(dir.path()).await;
1440 let bare = repo_path(dir.path(), &repo.id).expect("repo path");
1441 add_commit(&bare);
1442 let cookie = cookie_for("alice-token");
1443 let (_, _, body) = send(&app, "GET", "/alice/proj/settings/mirrors", &cookie, &[]).await;
1444 assert!(
1445 !body.contains("Add pull mirror"),
1446 "form shown on a non-empty repo"
1447 );
1448
1449 let (status, location, _) = send(
1450 &app,
1451 "POST",
1452 "/alice/proj/settings/mirrors",
1453 &cookie,
1454 &[("direction", "pull"), ("url", REMOTE), ("interval", "60")],
1455 )
1456 .await;
1457 assert_eq!(status, 303);
1458 assert!(
1459 location
1460 .unwrap_or_default()
1461 .contains("notice=pull-not-empty")
1462 );
1463 assert!(store.pull_mirror(&repo.id).await.expect("query").is_none());
1464 assert!(store.list_mirrors(&repo.id).await.expect("list").is_empty());
1465 }
1466
1467 #[tokio::test]
1468 async fn pull_mirror_add_is_404_for_non_admins() {
1469 let dir = tempfile::tempdir().expect("tempdir");
1470 let (store, app, repo) = fixture(dir.path()).await;
1471 for token in ["bob-token", "carol-token"] {
1472 let (status, _, _) = send(
1473 &app,
1474 "POST",
1475 "/alice/proj/settings/mirrors",
1476 &cookie_for(token),
1477 &[("direction", "pull"), ("url", REMOTE), ("interval", "60")],
1478 )
1479 .await;
1480 assert_eq!(status, 404, "pull add as {token}");
1481 }
1482 assert!(store.list_mirrors(&repo.id).await.expect("list").is_empty());
1483 }
1484}