forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
52.6 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 }
637 // A new mirror is due now, push or pull.
638 state.mirror_waker.notify_one();
639 Ok(Ok(mirror))
640}
641
642/// Stores a push mirror.
643async fn add_push_mirror(
644 lang: Lang,
645 state: &AppState,
646 ctx: &RepoCtx,
647 form: &PushForm,
648) -> Result<std::result::Result<Mirror, String>> {
649 store_mirror(lang, state, ctx, MirrorDirection::Push, form).await
650}
651
652/// Stores the pull mirror of an empty repo. A repo with refs is refused with a notice.
653async fn add_pull_mirror(
654 lang: Lang,
655 state: &AppState,
656 ctx: &RepoCtx,
657 form: &PushForm,
658) -> Result<std::result::Result<Mirror, String>> {
659 if !load_refs(ctx.path.clone()).await?.is_empty() {
660 return Err(back_to_list(&title_of(ctx), "pull-not-empty").into());
661 }
662 store_mirror(lang, state, ctx, MirrorDirection::Pull, form).await
663}
664
665/// Adds a push mirror, or a pull mirror when the form says `direction=pull`. The URL is checked
666/// before anything is stored.
667#[page(POST "/{owner}/{repo}/settings/mirrors")]
668async fn mirror_create(cx: &Cx, Form(form): Form<PushForm>) -> Result<impl View> {
669 let owner = path_param::<Owner>(cx);
670 let name = path_param::<Repo>(cx);
671 let ctx = load_repo(cx, owner, name).await?;
672 let (user, session) = require_csrf(cx, &form.csrf).await?;
673 if !ctx.can_admin() {
674 return Err(not_found().into());
675 }
676 if ctx.repo.archived {
677 return Err(forbidden().into());
678 }
679 let state = app_context::<AppState>(cx);
680 let lang = lang_of(cx, &ctx);
681 let pull = form.direction == "pull";
682 let stored = if pull {
683 add_pull_mirror(lang, state, &ctx, &form).await?
684 } else {
685 add_push_mirror(lang, state, &ctx, &form).await?
686 };
687 let mirror = match stored {
688 Ok(mirror) => mirror,
689 Err(message) => {
690 let (frame, rows) = list_data(state, &ctx, lang).await?;
691 let csrf = session.csrf_token.clone();
692 let typed = PushDraft {
693 url: form.url.trim().to_owned(),
694 username: form.username.trim().to_owned(),
695 interval: form.interval.trim().to_owned(),
696 error: Some(message),
697 };
698 let (draft, pull_form) = if pull {
699 (PushDraft::blank(), Some(typed))
700 } else {
701 (
702 typed,
703 can_add_pull(state, &ctx).await?.then(PushDraft::blank),
704 )
705 };
706 return Ok(view! {
707 shell(title: frame.page_title.as_str(), viewer: ctx.viewer.as_ref(),
708 mirrors_list_body(head: frame.head, title: frame.title, notice: None,
709 archived: frame.archived, csrf: csrf, rows: rows, draft: draft,
710 pull_form: pull_form, lang: lang))
711 });
712 }
713 };
714 audit::record(
715 &state.store,
716 Some(cx),
717 Actor::User(&user),
718 AuditAction::MirrorCreated,
719 Target {
720 kind: "repo",
721 id: &ctx.repo.id,
722 name: &title_of(&ctx),
723 },
724 serde_json::json!({
725 "mirror_id": mirror.id,
726 "direction": mirror.direction.as_str(),
727 "url": redacted_url(&mirror.url),
728 }),
729 )
730 .await;
731 tracing::info!(actor_id = %user.id, repo_id = %ctx.repo.id, mirror_id = %mirror.id, direction = %mirror.direction.as_str(), "mirror created");
732 let notice = if pull { "pull-created" } else { "created" };
733 Err(back_to_list(&title_of(&ctx), notice).into())
734}
735
736/// The mirror in the path, when it belongs to this repo. Any other mirror answers 404.
737async fn repo_mirror(state: &AppState, ctx: &RepoCtx, id: &str) -> Result<Mirror> {
738 match state.store.mirror(id).await? {
739 Some(mirror) if mirror.repo_id == ctx.repo.id => Ok(mirror),
740 _ => Err(not_found().into()),
741 }
742}
743
744/// The edit page body: the form, the sync and delete controls.
745#[component]
746async fn mirror_body(
747 head: RepoHead,
748 title: String,
749 notice: Option<(&'static str, bool)>,
750 archived: bool,
751 csrf: String,
752 mirror_id: String,
753 mirror: Mirror,
754 draft: EditDraft,
755 lang: Lang,
756) -> Result<impl View> {
757 let edit_action = mirror_href(&title, &mirror_id);
758 let sync_action = format!("{edit_action}/sync");
759 let delete_action = format!("{edit_action}/delete");
760 let pull = mirror.direction == MirrorDirection::Pull;
761 let last_sync = mirror
762 .last_sync_at
763 .as_deref()
764 .map(|stamp| stamp_in_tz(stamp, &draft.tz))
765 .unwrap_or_else(|| g::t(lang, K::Never).to_owned());
766 let delete_text = if pull {
767 g::t(lang, K::DeleteTextPull)
768 } else {
769 g::t(lang, K::DeleteTextPush)
770 };
771 let current_url = redacted_url(&mirror.url);
772 Ok(view! {
773 frame_top(head: head, title: title.clone(), notice: notice, archived: archived, lang: lang)
774 <section class="card">
775 <h2>(mirror_kind(lang, mirror.direction))</h2>
776 if archived {
777 <p><code>(current_url.as_str())</code></p>
778 <p class="muted small">(g::t(lang, K::LastSync))" " (last_sync.as_str())</p>
779 if let Some(error) = mirror.last_error.clone() {
780 <pre class="mirror-error">(error.as_str())</pre>
781 }
782 } else {
783 mirror_form(action: edit_action.clone(), csrf: csrf.clone(), pull: pull,
784 current_url: current_url.clone(), url: String::new(), username: draft.username,
785 has_secret: mirror.has_secret, interval: draft.interval, enabled: Some(draft.enabled),
786 error: draft.error, submit: g::t(lang, K::SaveMirror), direction: "", lang: lang)
787 }
788 </section>
789 if !archived {
790 <section class="card">
791 <h2>(g::t(lang, K::SyncHeading))</h2>
792 <p class="muted small">(g::t(lang, K::SyncIntro))</p>
793 <form method="post" action=(sync_action.as_str()) class="inline-form">
794 <input type="hidden" name="csrf" value=(csrf.as_str())>
795 <button type="submit">(g::t(lang, K::SyncNow))</button>
796 </form>
797 </section>
798 <section class="card">
799 <h2>(g::t(lang, K::DeleteMirror))</h2>
800 <p class="muted small">(delete_text)</p>
801 <form method="post" action=(delete_action.as_str()) class="stack">
802 <input type="hidden" name="csrf" value=(csrf.as_str())>
803 <label class="inline">
804 <input type="checkbox" name="confirm" value="on">
805 <span>(g::t(lang, K::DeleteConfirm))</span>
806 </label>
807 <div>
808 <button type="submit" class="danger">(g::t(lang, K::DeleteMirror))</button>
809 </div>
810 </form>
811 </section>
812 }
813 })
814}
815
816/// The edit form's values: the username and the interval as stored, or as submitted on error.
817struct EditDraft {
818 tz: String,
819 username: String,
820 interval: String,
821 enabled: bool,
822 error: Option<String>,
823}
824
825impl EditDraft {
826 fn of_mirror(mirror: &Mirror, tz: &str) -> Self {
827 Self {
828 tz: tz.to_owned(),
829 username: mirror.username.clone(),
830 interval: mirror.interval_minutes.to_string(),
831 enabled: mirror.enabled,
832 error: None,
833 }
834 }
835}
836
837/// The edit page for one mirror.
838#[page("/{owner}/{repo}/settings/mirrors/{mirror_id}")]
839async fn mirror_page(cx: &Cx) -> Result<impl View> {
840 let owner = path_param::<Owner>(cx);
841 let name = path_param::<Repo>(cx);
842 let id = path_param::<MirrorId>(cx);
843 let ctx = load_repo(cx, owner, name).await?;
844 let (_, session) = require_user(cx).await?;
845 if !ctx.can_admin() {
846 return Err(not_found().into());
847 }
848 let state = app_context::<AppState>(cx);
849 let mirror = repo_mirror(state, &ctx, id).await?;
850 let lang = lang_of(cx, &ctx);
851 let frame = Frame::new(&ctx, state, lang).await?;
852 let draft = EditDraft::of_mirror(&mirror, &frame.tz);
853 let csrf = session.csrf_token.clone();
854 let notice = notice_from_query(cx, lang);
855 let mirror_id = mirror.id.clone();
856 Ok(view! {
857 shell(title: frame.page_title.as_str(), viewer: ctx.viewer.as_ref(),
858 mirror_body(head: frame.head, title: frame.title, notice: notice,
859 archived: frame.archived, csrf: csrf, mirror_id: mirror_id, mirror: mirror,
860 draft: draft, lang: lang))
861 })
862}
863
864/// Saves a mirror. A blank URL keeps the stored one, a blank password keeps the stored password and
865/// the clear box removes it. A refused form shows the error on the edit page.
866#[page(POST "/{owner}/{repo}/settings/mirrors/{mirror_id}")]
867async fn mirror_update(cx: &Cx, Form(form): Form<EditForm>) -> Result<impl View> {
868 let owner = path_param::<Owner>(cx);
869 let name = path_param::<Repo>(cx);
870 let id = path_param::<MirrorId>(cx);
871 let ctx = load_repo(cx, owner, name).await?;
872 let (user, session) = require_csrf(cx, &form.csrf).await?;
873 if !ctx.can_admin() {
874 return Err(not_found().into());
875 }
876 if ctx.repo.archived {
877 return Err(forbidden().into());
878 }
879 let state = app_context::<AppState>(cx);
880 let mirror = repo_mirror(state, &ctx, id).await?;
881 let url = if form.url.trim().is_empty() {
882 mirror.url.clone()
883 } else {
884 form.url.trim().to_owned()
885 };
886 let lang = lang_of(cx, &ctx);
887 let checked = check_form_in(
888 lang,
889 state,
890 &url,
891 &form.username,
892 &form.secret,
893 &form.interval,
894 );
895 let checked = match checked {
896 Ok(checked) => checked,
897 Err(message) => {
898 let frame = Frame::new(&ctx, state, lang).await?;
899 let draft = EditDraft {
900 tz: frame.tz.clone(),
901 username: form.username.trim().to_owned(),
902 interval: form.interval.trim().to_owned(),
903 enabled: form.enabled.is_some(),
904 error: Some(message),
905 };
906 let csrf = session.csrf_token.clone();
907 let mirror_id = mirror.id.clone();
908 return Ok(view! {
909 shell(title: frame.page_title.as_str(), viewer: ctx.viewer.as_ref(),
910 mirror_body(head: frame.head, title: frame.title, notice: None,
911 archived: frame.archived, csrf: csrf, mirror_id: mirror_id, mirror: mirror,
912 draft: draft, lang: lang))
913 });
914 }
915 };
916 let secret_update = if !form.secret.trim().is_empty() {
917 SecretUpdate::Set(checked.secret.as_bytes())
918 } else if form.clear_secret.is_some() {
919 SecretUpdate::Clear
920 } else {
921 SecretUpdate::Keep
922 };
923 let cleared = matches!(secret_update, SecretUpdate::Clear);
924 state
925 .store
926 .update_mirror(
927 &mirror.id,
928 &checked.url,
929 &checked.username,
930 secret_update,
931 checked.interval,
932 form.enabled.is_some(),
933 )
934 .await?;
935 // The interval and the enabled flag decide when this mirror runs next.
936 state.mirror_waker.notify_one();
937 audit::record(
938 &state.store,
939 Some(cx),
940 Actor::User(&user),
941 AuditAction::MirrorUpdated,
942 Target {
943 kind: "repo",
944 id: &ctx.repo.id,
945 name: &title_of(&ctx),
946 },
947 serde_json::json!({
948 "mirror_id": mirror.id,
949 "direction": mirror.direction.as_str(),
950 "url": redacted_url(&checked.url),
951 "secret_changed": !matches!(secret_update, SecretUpdate::Keep),
952 "secret_cleared": cleared,
953 }),
954 )
955 .await;
956 tracing::info!(actor_id = %user.id, repo_id = %ctx.repo.id, mirror_id = %mirror.id, "mirror updated");
957 Err(back_to_mirror(&title_of(&ctx), &mirror.id, "saved").into())
958}
959
960/// Queues a sync now: the mirror is due at the current time and the worker is woken.
961#[route(POST "/{owner}/{repo}/settings/mirrors/{mirror_id}/sync")]
962async fn mirror_sync(cx: &Cx, Form(form): Form<CsrfForm>) -> Result<SeeOther> {
963 let owner = path_param::<Owner>(cx);
964 let name = path_param::<Repo>(cx);
965 let id = path_param::<MirrorId>(cx);
966 let ctx = load_repo(cx, owner, name).await?;
967 require_csrf(cx, &form.csrf).await?;
968 if !ctx.can_admin() {
969 return Err(not_found().into());
970 }
971 if ctx.repo.archived {
972 return Err(forbidden().into());
973 }
974 let state = app_context::<AppState>(cx);
975 let mirror = repo_mirror(state, &ctx, id).await?;
976 state
977 .store
978 .schedule_mirror(&mirror.id, &now_stamp())
979 .await?;
980 state.mirror_waker.notify_one();
981 Ok(back_to_list(&title_of(&ctx), "synced"))
982}
983
984/// Deletes a mirror. The box must be ticked. Deleting the pull mirror makes the repo a normal one.
985#[route(POST "/{owner}/{repo}/settings/mirrors/{mirror_id}/delete")]
986async fn mirror_delete(cx: &Cx, Form(form): Form<DeleteForm>) -> Result<SeeOther> {
987 let owner = path_param::<Owner>(cx);
988 let name = path_param::<Repo>(cx);
989 let id = path_param::<MirrorId>(cx);
990 let ctx = load_repo(cx, owner, name).await?;
991 let (user, _) = require_csrf(cx, &form.csrf).await?;
992 if !ctx.can_admin() {
993 return Err(not_found().into());
994 }
995 if ctx.repo.archived {
996 return Err(forbidden().into());
997 }
998 let state = app_context::<AppState>(cx);
999 let mirror = repo_mirror(state, &ctx, id).await?;
1000 let title = title_of(&ctx);
1001 if form.confirm.is_none() {
1002 return Ok(back_to_mirror(&title, &mirror.id, "confirm"));
1003 }
1004 state.store.delete_mirror(&mirror.id).await?;
1005 audit::record(
1006 &state.store,
1007 Some(cx),
1008 Actor::User(&user),
1009 AuditAction::MirrorDeleted,
1010 Target {
1011 kind: "repo",
1012 id: &ctx.repo.id,
1013 name: &title,
1014 },
1015 serde_json::json!({
1016 "mirror_id": mirror.id,
1017 "direction": mirror.direction.as_str(),
1018 "url": redacted_url(&mirror.url),
1019 }),
1020 )
1021 .await;
1022 tracing::info!(actor_id = %user.id, repo_id = %ctx.repo.id, mirror_id = %mirror.id, "mirror deleted");
1023 Ok(back_to_list(&title, "deleted"))
1024}
1025
1026#[cfg(test)]
1027mod tests {
1028 use std::path::Path;
1029
1030 use forge_core::mirrors::{MirrorDirection, NewMirror};
1031 use forge_core::time::now_stamp;
1032 use forge_core::{Role, Store, Visibility};
1033 use forge_git::repo::{init_bare, repo_path};
1034 use topcoat::router::request::Request;
1035 use topcoat::router::{Body, Router, to_bytes};
1036
1037 use super::redacted_url;
1038 use crate::auth::SESSION_COOKIE;
1039 use crate::test_support::{app_router, create_session, test_state};
1040
1041 const CSRF: &str = "mirrors-test-csrf";
1042 const REMOTE: &str = "https://example.com/upstream/proj.git";
1043
1044 /// Alice owns `proj` (private), bob is a Write collaborator, carol is outside the repo.
1045 async fn fixture(dir: &Path) -> (Store, Router, forge_core::Repo) {
1046 let state = test_state(dir).await;
1047 let store = state.store.clone();
1048 let alice = store
1049 .create_user("alice", None, "unused", false)
1050 .await
1051 .expect("alice");
1052 let bob = store
1053 .create_user("bob", None, "unused", false)
1054 .await
1055 .expect("bob");
1056 store
1057 .create_user("carol", None, "unused", false)
1058 .await
1059 .expect("carol");
1060 let repo = store
1061 .create_repo(&alice.id, "proj", "", Visibility::Private, "main")
1062 .await
1063 .expect("repo");
1064 let bare = repo_path(&state.git.data_dir, &repo.id).expect("repo path");
1065 init_bare(&state.git, &bare, "main")
1066 .await
1067 .expect("init bare");
1068 store
1069 .set_collaborator(&repo.id, &bob.id, Role::Write)
1070 .await
1071 .expect("collaborator");
1072 create_session(&store, &alice.id, "alice-token", CSRF).await;
1073 create_session(&store, &bob.id, "bob-token", CSRF).await;
1074 let carol = store
1075 .user_by_username("carol")
1076 .await
1077 .expect("lookup")
1078 .expect("carol");
1079 create_session(&store, &carol.id, "carol-token", CSRF).await;
1080 (store, app_router(state), repo)
1081 }
1082
1083 fn cookie_for(token: &str) -> String {
1084 format!("__Host-{SESSION_COOKIE}={token}")
1085 }
1086
1087 /// Sends a request. A POST carries the CSRF token and the given fields as a form body.
1088 /// Returns the status, the Location header (if any) and the body.
1089 async fn send(
1090 app: &Router,
1091 method: &str,
1092 uri: &str,
1093 cookie: &str,
1094 fields: &[(&str, &str)],
1095 ) -> (u16, Option<String>, String) {
1096 let mut form = form_urlencoded::Serializer::new(String::new());
1097 if method == "POST" {
1098 form.append_pair("csrf", CSRF);
1099 }
1100 for (name, value) in fields {
1101 form.append_pair(name, value);
1102 }
1103 let mut builder = Request::builder()
1104 .method(method)
1105 .uri(uri)
1106 .header("cookie", cookie);
1107 if method == "POST" {
1108 builder = builder.header("content-type", "application/x-www-form-urlencoded");
1109 }
1110 let req = builder.body(Body::from(form.finish())).expect("request");
1111 let res = app.handle(req).await;
1112 let status = res.status().as_u16();
1113 let location = res
1114 .headers()
1115 .get("location")
1116 .and_then(|value| value.to_str().ok())
1117 .map(str::to_owned);
1118 let body = to_bytes(res.into_body(), usize::MAX).await.expect("body");
1119 (
1120 status,
1121 location,
1122 String::from_utf8_lossy(&body).into_owned(),
1123 )
1124 }
1125
1126 async fn push_mirror(store: &Store, repo: &forge_core::Repo, secret: Option<&[u8]>) -> String {
1127 store
1128 .create_mirror(&NewMirror {
1129 repo_id: &repo.id,
1130 direction: MirrorDirection::Push,
1131 url: "https://example.com/remote.git?token=abc123",
1132 username: "bot",
1133 secret,
1134 interval_minutes: 480,
1135 })
1136 .await
1137 .expect("mirror")
1138 .id
1139 }
1140
1141 #[test]
1142 fn redacted_url_keeps_host_port_and_path_only() {
1143 assert_eq!(
1144 redacted_url("https://example.com:8443/a/b.git?token=abc#frag"),
1145 "example.com:8443/a/b.git"
1146 );
1147 assert_eq!(redacted_url(REMOTE), "example.com/upstream/proj.git");
1148 assert_eq!(redacted_url("not a url"), "(unreadable URL)");
1149 }
1150
1151 #[tokio::test]
1152 async fn non_admins_get_not_found() {
1153 let dir = tempfile::tempdir().expect("tempdir");
1154 let (store, app, repo) = fixture(dir.path()).await;
1155 let id = push_mirror(&store, &repo, None).await;
1156 for token in ["bob-token", "carol-token"] {
1157 let (status, _, _) = send(
1158 &app,
1159 "GET",
1160 "/alice/proj/settings/mirrors",
1161 &cookie_for(token),
1162 &[],
1163 )
1164 .await;
1165 assert_eq!(status, 404, "list as {token}");
1166 let (status, _, _) = send(
1167 &app,
1168 "GET",
1169 &format!("/alice/proj/settings/mirrors/{id}"),
1170 &cookie_for(token),
1171 &[],
1172 )
1173 .await;
1174 assert_eq!(status, 404, "edit as {token}");
1175 let (status, _, _) = send(
1176 &app,
1177 "POST",
1178 &format!("/alice/proj/settings/mirrors/{id}/sync"),
1179 &cookie_for(token),
1180 &[],
1181 )
1182 .await;
1183 assert_eq!(status, 404, "sync as {token}");
1184 }
1185 let (status, _, _) = send(
1186 &app,
1187 "GET",
1188 "/alice/proj/settings/mirrors",
1189 &cookie_for("alice-token"),
1190 &[],
1191 )
1192 .await;
1193 assert_eq!(status, 200);
1194 }
1195
1196 #[tokio::test]
1197 async fn list_page_follows_the_stored_turkish_language() {
1198 let dir = tempfile::tempdir().expect("tempdir");
1199 let (store, app, _repo) = fixture(dir.path()).await;
1200 let alice = store
1201 .user_by_username("alice")
1202 .await
1203 .expect("lookup")
1204 .expect("alice");
1205 store
1206 .set_ui_prefs(&alice.id, "auto", "tr")
1207 .await
1208 .expect("set language");
1209 let (status, _, body) = send(
1210 &app,
1211 "GET",
1212 "/alice/proj/settings/mirrors",
1213 &cookie_for("alice-token"),
1214 &[],
1215 )
1216 .await;
1217 assert_eq!(status, 200, "{body}");
1218 assert!(
1219 body.contains("<h2>Yansılar</h2>"),
1220 "Turkish heading missing"
1221 );
1222 }
1223
1224 #[tokio::test]
1225 async fn secret_is_never_rendered_and_urls_are_redacted() {
1226 let dir = tempfile::tempdir().expect("tempdir");
1227 let (store, app, repo) = fixture(dir.path()).await;
1228 let id = push_mirror(&store, &repo, Some(b"hunter2-secret")).await;
1229 let cookie = cookie_for("alice-token");
1230 for uri in [
1231 "/alice/proj/settings/mirrors".to_owned(),
1232 format!("/alice/proj/settings/mirrors/{id}"),
1233 ] {
1234 let (status, _, body) = send(&app, "GET", &uri, &cookie, &[]).await;
1235 assert_eq!(status, 200, "{uri}");
1236 assert!(!body.contains("hunter2-secret"), "secret rendered on {uri}");
1237 assert!(!body.contains("abc123"), "query string rendered on {uri}");
1238 assert!(
1239 body.contains("example.com/remote.git"),
1240 "redacted URL missing on {uri}"
1241 );
1242 }
1243 assert!(
1244 store
1245 .mirror(&id)
1246 .await
1247 .expect("query")
1248 .expect("mirror")
1249 .has_secret
1250 );
1251 }
1252
1253 #[tokio::test]
1254 async fn loopback_url_is_rejected_by_the_form() {
1255 let dir = tempfile::tempdir().expect("tempdir");
1256 let (store, app, repo) = fixture(dir.path()).await;
1257 let (status, _, body) = send(
1258 &app,
1259 "POST",
1260 "/alice/proj/settings/mirrors",
1261 &cookie_for("alice-token"),
1262 &[("url", "https://127.0.0.1/remote.git"), ("interval", "480")],
1263 )
1264 .await;
1265 assert_eq!(status, 200);
1266 assert!(body.contains("destination not allowed"), "error not shown");
1267 assert!(store.list_mirrors(&repo.id).await.expect("list").is_empty());
1268 }
1269
1270 #[tokio::test]
1271 async fn add_push_mirror_then_sync_now_schedules_it() {
1272 let dir = tempfile::tempdir().expect("tempdir");
1273 let (store, app, repo) = fixture(dir.path()).await;
1274 let cookie = cookie_for("alice-token");
1275 let (status, location, _) = send(
1276 &app,
1277 "POST",
1278 "/alice/proj/settings/mirrors",
1279 &cookie,
1280 &[
1281 ("url", REMOTE),
1282 ("username", "bot"),
1283 ("secret", "hunter2-secret"),
1284 ("interval", "60"),
1285 ],
1286 )
1287 .await;
1288 assert_eq!(status, 303);
1289 assert!(location.unwrap_or_default().contains("notice=created"));
1290 let mirrors = store.list_mirrors(&repo.id).await.expect("list");
1291 assert_eq!(mirrors.len(), 1);
1292 let mirror = &mirrors[0];
1293 assert_eq!(mirror.direction, MirrorDirection::Push);
1294 assert_eq!(mirror.url, REMOTE);
1295 assert!(mirror.has_secret);
1296
1297 let (status, _, _) = send(
1298 &app,
1299 "POST",
1300 &format!("/alice/proj/settings/mirrors/{}/sync", mirror.id),
1301 &cookie,
1302 &[],
1303 )
1304 .await;
1305 assert_eq!(status, 303);
1306 let synced = store
1307 .mirror(&mirror.id)
1308 .await
1309 .expect("query")
1310 .expect("mirror");
1311 assert!(
1312 synced.next_sync_at <= now_stamp(),
1313 "sync now makes the mirror due"
1314 );
1315 }
1316
1317 #[tokio::test]
1318 async fn edit_clears_the_password_on_request() {
1319 let dir = tempfile::tempdir().expect("tempdir");
1320 let (store, app, repo) = fixture(dir.path()).await;
1321 let id = push_mirror(&store, &repo, Some(b"hunter2-secret")).await;
1322 let (status, _, _) = send(
1323 &app,
1324 "POST",
1325 &format!("/alice/proj/settings/mirrors/{id}"),
1326 &cookie_for("alice-token"),
1327 &[
1328 ("url", ""),
1329 ("username", "bot"),
1330 ("interval", "480"),
1331 ("enabled", "on"),
1332 ("clear_secret", "on"),
1333 ],
1334 )
1335 .await;
1336 assert_eq!(status, 303);
1337 let mirror = store.mirror(&id).await.expect("query").expect("mirror");
1338 assert!(!mirror.has_secret);
1339 assert_eq!(
1340 mirror.url, "https://example.com/remote.git?token=abc123",
1341 "a blank URL keeps the stored one"
1342 );
1343 }
1344
1345 #[tokio::test]
1346 async fn delete_needs_the_confirm_box() {
1347 let dir = tempfile::tempdir().expect("tempdir");
1348 let (store, app, repo) = fixture(dir.path()).await;
1349 let id = push_mirror(&store, &repo, None).await;
1350 let cookie = cookie_for("alice-token");
1351 let uri = format!("/alice/proj/settings/mirrors/{id}/delete");
1352 let (status, _, _) = send(&app, "POST", &uri, &cookie, &[]).await;
1353 assert_eq!(status, 303);
1354 assert!(
1355 store.mirror(&id).await.expect("query").is_some(),
1356 "nothing deleted without the box"
1357 );
1358 let (status, location, _) = send(&app, "POST", &uri, &cookie, &[("confirm", "on")]).await;
1359 assert_eq!(status, 303);
1360 assert!(location.unwrap_or_default().contains("notice=deleted"));
1361 assert!(store.mirror(&id).await.expect("query").is_none());
1362 }
1363
1364 /// Gives the bare repo at `bare` a commit on `main`, so it has refs and is no longer empty.
1365 fn add_commit(bare: &Path) {
1366 let run = |args: &[&str]| -> String {
1367 let out = std::process::Command::new("git")
1368 .arg(format!("--git-dir={}", bare.display()))
1369 .args(args)
1370 .env("GIT_AUTHOR_NAME", "t")
1371 .env("GIT_AUTHOR_EMAIL", "t@example.com")
1372 .env("GIT_COMMITTER_NAME", "t")
1373 .env("GIT_COMMITTER_EMAIL", "t@example.com")
1374 .output()
1375 .expect("git runs");
1376 assert!(out.status.success(), "git {args:?}: {out:?}");
1377 String::from_utf8_lossy(&out.stdout).trim().to_string()
1378 };
1379 let tree = run(&["hash-object", "-t", "tree", "-w", "--stdin"]);
1380 let commit = run(&["commit-tree", &tree, "-m", "first"]);
1381 run(&["update-ref", "refs/heads/main", &commit]);
1382 }
1383
1384 #[tokio::test]
1385 async fn pull_mirror_can_be_added_to_an_empty_repo() {
1386 let dir = tempfile::tempdir().expect("tempdir");
1387 let (store, app, repo) = fixture(dir.path()).await;
1388 let cookie = cookie_for("alice-token");
1389 let (status, _, body) =
1390 send(&app, "GET", "/alice/proj/settings/mirrors", &cookie, &[]).await;
1391 assert_eq!(status, 200);
1392 assert!(
1393 body.contains("Add pull mirror"),
1394 "form missing on an empty repo"
1395 );
1396
1397 let (status, location, _) = send(
1398 &app,
1399 "POST",
1400 "/alice/proj/settings/mirrors",
1401 &cookie,
1402 &[
1403 ("direction", "pull"),
1404 ("url", REMOTE),
1405 ("username", "bot"),
1406 ("secret", "hunter2-secret"),
1407 ("interval", "60"),
1408 ],
1409 )
1410 .await;
1411 assert_eq!(status, 303);
1412 assert!(location.unwrap_or_default().contains("notice=pull-created"));
1413 let mirror = store
1414 .pull_mirror(&repo.id)
1415 .await
1416 .expect("query")
1417 .expect("a pull mirror");
1418 assert_eq!(mirror.direction, MirrorDirection::Pull);
1419 assert_eq!(mirror.url, REMOTE);
1420 assert!(mirror.has_secret);
1421 assert!(
1422 mirror.next_sync_at <= now_stamp(),
1423 "the first sync is due now"
1424 );
1425
1426 // Audited like the push mirror.
1427 let filter = forge_core::AuditFilter {
1428 action: Some(forge_core::AuditAction::MirrorCreated),
1429 ..Default::default()
1430 };
1431 let audit = store.list_audit(&filter, None, 10).await.expect("audit");
1432 assert_eq!(audit.len(), 1, "mirror_created audited once");
1433
1434 // Once a pull mirror exists the form is gone.
1435 let (_, _, body) = send(&app, "GET", "/alice/proj/settings/mirrors", &cookie, &[]).await;
1436 assert!(!body.contains("Add pull mirror"), "form still shown");
1437 }
1438
1439 #[tokio::test]
1440 async fn pull_mirror_is_refused_on_a_repo_with_refs() {
1441 let dir = tempfile::tempdir().expect("tempdir");
1442 let (store, app, repo) = fixture(dir.path()).await;
1443 let bare = repo_path(dir.path(), &repo.id).expect("repo path");
1444 add_commit(&bare);
1445 let cookie = cookie_for("alice-token");
1446 let (_, _, body) = send(&app, "GET", "/alice/proj/settings/mirrors", &cookie, &[]).await;
1447 assert!(
1448 !body.contains("Add pull mirror"),
1449 "form shown on a non-empty repo"
1450 );
1451
1452 let (status, location, _) = send(
1453 &app,
1454 "POST",
1455 "/alice/proj/settings/mirrors",
1456 &cookie,
1457 &[("direction", "pull"), ("url", REMOTE), ("interval", "60")],
1458 )
1459 .await;
1460 assert_eq!(status, 303);
1461 assert!(
1462 location
1463 .unwrap_or_default()
1464 .contains("notice=pull-not-empty")
1465 );
1466 assert!(store.pull_mirror(&repo.id).await.expect("query").is_none());
1467 assert!(store.list_mirrors(&repo.id).await.expect("list").is_empty());
1468 }
1469
1470 #[tokio::test]
1471 async fn pull_mirror_add_is_404_for_non_admins() {
1472 let dir = tempfile::tempdir().expect("tempdir");
1473 let (store, app, repo) = fixture(dir.path()).await;
1474 for token in ["bob-token", "carol-token"] {
1475 let (status, _, _) = send(
1476 &app,
1477 "POST",
1478 "/alice/proj/settings/mirrors",
1479 &cookie_for(token),
1480 &[("direction", "pull"), ("url", REMOTE), ("interval", "60")],
1481 )
1482 .await;
1483 assert_eq!(status, 404, "pull add as {token}");
1484 }
1485 assert!(store.list_mirrors(&repo.id).await.expect("list").is_empty());
1486 }
1487}