forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
23.5 KiB
1//! Page: create a repository (GET/POST /new).
2
3use forge_core::mirrors::{DEFAULT_INTERVAL_MINUTES, MirrorDirection, NewMirror};
4use forge_core::names::validate_name;
5use forge_core::time::now_stamp;
6use forge_core::{AuditAction, User, Visibility};
7use forge_git::repo::{init_bare, repo_path};
8use serde::Deserialize;
9use topcoat::Result;
10use topcoat::context::{Cx, app_context};
11use topcoat::router::content::Form;
12use topcoat::router::error::see_other;
13use topcoat::router::page;
14use topcoat::view::{View, component, view};
15
16use crate::audit::{self, Actor, Target};
17use crate::auth::{require_csrf, require_user};
18use crate::i18n::community::{self as community, K as CommunityK};
19use crate::i18n::misc::{self as g, K};
20use crate::i18n::{self, Key, Lang};
21use crate::pages::org_ctx::may_create_repo_in;
22use crate::pages::repo_mirrors::{Checked, check_form_in, redacted_url};
23use crate::state::AppState;
24use crate::views::{alert, csrf_field, shell, user_facing_message};
25
26const MAX_DESCRIPTION_CHARS: usize = 350;
27const DEFAULT_BRANCH: &str = "main";
28
29#[derive(Deserialize)]
30struct NewRepoForm {
31 #[serde(default)]
32 csrf: String,
33 #[serde(default)]
34 name: String,
35 #[serde(default)]
36 description: String,
37 #[serde(default)]
38 visibility: String,
39 #[serde(default)]
40 default_branch: String,
41 /// The checkbox sends `on` when checked and nothing when unchecked.
42 #[serde(default)]
43 default_labels: String,
44 /// The optional "Mirror an existing repository" section. A blank URL means no mirror.
45 #[serde(default)]
46 mirror_url: String,
47 #[serde(default)]
48 mirror_username: String,
49 #[serde(default)]
50 mirror_secret: String,
51 #[serde(default)]
52 mirror_interval: String,
53 /// The account that will own the repo: the viewer's own name, or an org they may create in.
54 /// Blank means the viewer.
55 #[serde(default)]
56 owner: String,
57}
58
59/// The form's values, echoed back when validation fails.
60struct Draft {
61 owner: String,
62 name: String,
63 description: String,
64 private: bool,
65 default_branch: String,
66 default_labels: bool,
67 mirror_url: String,
68 mirror_username: String,
69 mirror_interval: String,
70}
71
72impl Draft {
73 fn fresh() -> Self {
74 Draft {
75 owner: String::new(),
76 name: String::new(),
77 description: String::new(),
78 private: true,
79 default_branch: DEFAULT_BRANCH.into(),
80 default_labels: true,
81 mirror_url: String::new(),
82 mirror_username: String::new(),
83 mirror_interval: DEFAULT_INTERVAL_MINUTES.to_string(),
84 }
85 }
86
87 /// The submitted values. The mirror password is never echoed back.
88 fn from_form(form: &NewRepoForm) -> Self {
89 Draft {
90 owner: form.owner.trim().to_owned(),
91 name: form.name.clone(),
92 description: form.description.clone(),
93 private: form.visibility != "public",
94 default_branch: form.default_branch.trim().to_owned(),
95 default_labels: !form.default_labels.is_empty(),
96 mirror_url: form.mirror_url.trim().to_owned(),
97 mirror_username: form.mirror_username.trim().to_owned(),
98 mirror_interval: form.mirror_interval.trim().to_owned(),
99 }
100 }
101}
102
103#[component]
104async fn new_repo_form(
105 lang: Lang,
106 csrf: &str,
107 draft: Draft,
108 error: Option<String>,
109 owners: Vec<String>,
110) -> Result<impl View> {
111 let branch = if draft.default_branch.is_empty() {
112 DEFAULT_BRANCH.to_owned()
113 } else {
114 draft.default_branch
115 };
116 let selected_owner = if draft.owner.is_empty() {
117 owners.first().cloned().unwrap_or_default()
118 } else {
119 draft.owner.clone()
120 };
121 let private = draft.private;
122 let default_labels = draft.default_labels;
123 // A mirror URL in the draft means the form was re-rendered after a validation error.
124 let mirror_open = !draft.mirror_url.is_empty();
125 let draft_mirror_url = draft.mirror_url;
126 let draft_mirror_username = draft.mirror_username;
127 let draft_mirror_interval = draft.mirror_interval;
128 Ok(view! {
129 if let Some(message) = error {
130 alert(message: message.as_str())
131 }
132 <form method="post" action="/new" class="newrepo-form">
133 csrf_field(token: csrf)
134 <header class="newrepo-head">
135 <h1>(i18n::t(lang, Key::NewRepository))</h1>
136 <div class="newrepo-head-actions">
137 <fieldset class="newrepo-visibility">
138 <legend class="sr-only">(g::t(lang, K::VisibilityLegend))</legend>
139 <div class="segmented snippet-segmented">
140 <label class="segment" title=(g::t(lang, K::PrivateHelp))>
141 <input type="radio" name="visibility" value="private" checked=(private)>
142 <svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>
143 <span>(community::t(lang, CommunityK::VisibilityPrivate))</span>
144 </label>
145 <label class="segment" title=(g::t(lang, K::PublicHelp))>
146 <input type="radio" name="visibility" value="public" checked=(!private)>
147 <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></svg>
148 <span>(community::t(lang, CommunityK::VisibilityPublic))</span>
149 </label>
150 </div>
151 </fieldset>
152 <button type="submit" class="primary">(g::t(lang, K::CreateRepository))</button>
153 </div>
154 </header>
155 <div class="newrepo-card">
156 <div class="newrepo-row newrepo-slug">
157 <label class="newrepo-owner">
158 <span>(g::t(lang, K::NewOwner))</span>
159 <select name="owner">
160 for name in owners.iter() {
161 <option value=(name.as_str()) selected=(*name == selected_owner)>(name.as_str())</option>
162 }
163 </select>
164 </label>
165 <span class="newrepo-slash" aria-hidden="true">("/")</span>
166 <label class="newrepo-name">
167 <span>(g::t(lang, K::RepoName))</span>
168 <input type="text" name="name" value=(draft.name) required=(true) maxlength="64" autocomplete="off" class="newrepo-mono">
169 </label>
170 </div>
171 <label>
172 <span>(g::t(lang, K::DescriptionOptional))</span>
173 <textarea name="description" rows="3" maxlength="350">(draft.description)</textarea>
174 </label>
175 <div class="newrepo-row newrepo-options">
176 <label class="newrepo-branch">
177 <span>(g::t(lang, K::DefaultBranch))</span>
178 <input type="text" name="default_branch" value=(branch) maxlength="255" autocomplete="off" class="newrepo-mono">
179 </label>
180 <label class="inline newrepo-labels">
181 <input type="checkbox" name="default_labels" value="on" checked=(default_labels)>
182 <span>(g::t(lang, K::AddDefaultLabels))</span>
183 </label>
184 </div>
185 </div>
186 <details class="newrepo-mirror" open=(mirror_open)>
187 <summary>
188 <span>(g::t(lang, K::MirrorLegend))</span>
189 <svg viewBox="0 0 24 24" aria-hidden="true" class="newrepo-chev"><path d="M6 9l6 6 6-6"/></svg>
190 </summary>
191 <div class="newrepo-mirror-body">
192 <p class="muted small">(g::t(lang, K::MirrorIntro))</p>
193 <label>
194 <span>(g::t(lang, K::SourceUrl))</span>
195 <input type="url" name="mirror_url" value=(draft_mirror_url.as_str()) autocomplete="off" spellcheck="false" maxlength="2048">
196 </label>
197 <div class="newrepo-row">
198 <label class="newrepo-half">
199 <span>(g::t(lang, K::UsernameOptional))</span>
200 <input type="text" name="mirror_username" value=(draft_mirror_username.as_str()) autocomplete="off" maxlength="255">
201 </label>
202 <label class="newrepo-half">
203 <span>(g::t(lang, K::PasswordOrToken))</span>
204 <input type="password" name="mirror_secret" autocomplete="new-password" maxlength="1024">
205 </label>
206 </div>
207 <label class="newrepo-interval">
208 <span>(g::t(lang, K::SyncInterval))</span>
209 <input type="number" name="mirror_interval" value=(draft_mirror_interval.as_str()) min="0" max="525600" step="1">
210 </label>
211 </div>
212 </details>
213 </form>
214 })
215}
216
217/// The accounts the viewer may create a repo under: themselves, then the orgs where
218/// `may_create_repo_in` allows it.
219async fn owner_choices(state: &AppState, viewer: &User) -> Result<Vec<String>> {
220 let mut names = vec![viewer.username.clone()];
221 for (org, _) in state.store.orgs_for_user(&viewer.id).await? {
222 if may_create_repo_in(state, &org.id, &viewer.id).await? {
223 names.push(org.username);
224 }
225 }
226 Ok(names)
227}
228
229#[page("/new")]
230async fn new_repo_page(cx: &Cx) -> Result<impl View> {
231 let viewer = require_user(cx).await?;
232 let lang = i18n::lang(cx, Some(&viewer.0));
233 let owners = owner_choices(app_context::<AppState>(cx), &viewer.0).await?;
234 let csrf = viewer.1.csrf_token.clone();
235 Ok(view! { cx =>
236 shell(title: i18n::t(lang, Key::NewRepository), viewer: Some(&viewer), new_repo_form(lang: lang, csrf: csrf.as_str(), draft: Draft::fresh(), error: None, owners: owners))
237 })
238}
239
240#[page(POST "/new")]
241async fn new_repo_submit(cx: &Cx, Form(form): Form<NewRepoForm>) -> Result<impl View> {
242 let viewer = require_csrf(cx, &form.csrf).await?;
243 let lang = i18n::lang(cx, Some(&viewer.0));
244 let outcome = create_repo(cx, &viewer.0, &form).await?;
245 match outcome {
246 Ok(location) => Err(see_other(location).into()),
247 Err(message) => {
248 let owners = owner_choices(app_context::<AppState>(cx), &viewer.0).await?;
249 let csrf = viewer.1.csrf_token.clone();
250 let draft = Draft::from_form(&form);
251 Ok(view! { cx =>
252 shell(title: i18n::t(lang, Key::NewRepository), viewer: Some(&viewer), new_repo_form(lang: lang, csrf: csrf.as_str(), draft: draft, error: Some(message), owners: owners))
253 })
254 }
255 }
256}
257
258/// The account the new repo belongs to. Blank or the viewer's own name means the viewer. An org
259/// name is accepted only when the viewer may create repos under it.
260async fn repo_owner(
261 state: &AppState,
262 viewer: &User,
263 lang: Lang,
264 requested: &str,
265) -> Result<std::result::Result<User, String>> {
266 let requested = requested.trim();
267 if requested.is_empty() || requested.eq_ignore_ascii_case(&viewer.username) {
268 return Ok(Ok(viewer.clone()));
269 }
270 let refused = g::t(lang, K::OwnerRefused).to_string();
271 let Some(org) = state.store.user_by_username(requested).await? else {
272 return Ok(Err(refused));
273 };
274 if !org.is_org() || !may_create_repo_in(state, &org.id, &viewer.id).await? {
275 return Ok(Err(refused));
276 }
277 Ok(Ok(org))
278}
279
280/// Validates the form and creates the repo. `Ok(Err(message))` is a user-facing rejection;
281/// the outer `Err` is an internal failure.
282async fn create_repo(
283 cx: &Cx,
284 owner: &User,
285 form: &NewRepoForm,
286) -> Result<std::result::Result<String, String>> {
287 let state = app_context::<AppState>(cx);
288 let lang = i18n::lang(cx, Some(owner));
289 let name = match validate_name(&form.name) {
290 Ok(name) => name,
291 Err(e) => {
292 return Ok(Err(user_facing_message(&e)
293 .unwrap_or_else(|| g::t(lang, K::InvalidRepoName).to_owned())));
294 }
295 };
296 if form.description.chars().count() > MAX_DESCRIPTION_CHARS {
297 return Ok(Err(g::description_too_long(lang, MAX_DESCRIPTION_CHARS)));
298 }
299 let visibility = match form.visibility.as_str() {
300 "public" => Visibility::Public,
301 _ => Visibility::Private,
302 };
303 let branch = match form.default_branch.trim() {
304 "" => DEFAULT_BRANCH,
305 other => other,
306 };
307 let add_default_labels = !form.default_labels.is_empty();
308 let target = match repo_owner(state, owner, lang, &form.owner).await? {
309 Ok(target) => target,
310 Err(message) => return Ok(Err(message)),
311 };
312 // The mirror is checked before anything is created, so a refused URL leaves no repo behind.
313 let mirror = if form.mirror_url.trim().is_empty() {
314 None
315 } else {
316 match check_form_in(
317 lang,
318 state,
319 &form.mirror_url,
320 &form.mirror_username,
321 &form.mirror_secret,
322 &form.mirror_interval,
323 ) {
324 Ok(checked) => Some(checked),
325 Err(message) => return Ok(Err(message)),
326 }
327 };
328
329 let repo = match state
330 .store
331 .create_repo(
332 &target.id,
333 &name,
334 form.description.trim(),
335 visibility,
336 branch,
337 )
338 .await
339 {
340 Ok(repo) => repo,
341 Err(e) => match user_facing_message(&e) {
342 Some(message) => return Ok(Err(message)),
343 None => return Err(e.into()),
344 },
345 };
346
347 let path = repo_path(&state.config.data_dir, &repo.id)?;
348 if let Err(e) = init_bare(&state.git, &path, branch).await {
349 // TODO: delete the repository row so a failed init does not leave a dangling name.
350 return Ok(Err(g::git_init_failed(lang, e)));
351 }
352 // The repo already exists at this point, so a label failure is logged rather than shown as an
353 // error page for a repo that was in fact created.
354 if add_default_labels && let Err(e) = state.store.create_default_labels(&repo.id).await {
355 tracing::warn!(error = %e, repo_id = %repo.id, "new repo: could not add default labels");
356 }
357 if let Some(checked) = &mirror {
358 create_pull_mirror(cx, state, owner, &repo, checked).await?;
359 }
360 let full_name = format!("{}/{}", repo.owner_name, repo.name);
361 audit::record(
362 &state.store,
363 Some(cx),
364 Actor::User(owner),
365 AuditAction::RepoCreated,
366 Target {
367 kind: "repo",
368 id: &repo.id,
369 name: &full_name,
370 },
371 serde_json::Value::Null,
372 )
373 .await;
374 Ok(Ok(format!("/{}/{}", repo.owner_name, repo.name)))
375}
376
377/// Creates the pull mirror of a new repo and queues its first sync now. The repo is new and empty,
378/// so a store failure here is an internal error.
379async fn create_pull_mirror(
380 cx: &Cx,
381 state: &AppState,
382 owner: &User,
383 repo: &forge_core::Repo,
384 checked: &Checked,
385) -> Result<()> {
386 let secret = (!checked.secret.is_empty()).then_some(checked.secret.as_bytes());
387 let mirror = state
388 .store
389 .create_mirror(&NewMirror {
390 repo_id: &repo.id,
391 direction: MirrorDirection::Pull,
392 url: &checked.url,
393 username: &checked.username,
394 secret,
395 interval_minutes: checked.interval,
396 })
397 .await?;
398 state
399 .store
400 .schedule_mirror(&mirror.id, &now_stamp())
401 .await?;
402 state.mirror_waker.notify_one();
403 audit::record(
404 &state.store,
405 Some(cx),
406 Actor::User(owner),
407 AuditAction::MirrorCreated,
408 Target {
409 kind: "repo",
410 id: &repo.id,
411 name: &format!("{}/{}", repo.owner_name, repo.name),
412 },
413 serde_json::json!({
414 "mirror_id": mirror.id,
415 "direction": MirrorDirection::Pull.as_str(),
416 "url": redacted_url(&mirror.url),
417 }),
418 )
419 .await;
420 Ok(())
421}
422
423#[cfg(test)]
424mod tests {
425 use forge_core::Store;
426 use forge_core::mirrors::MirrorDirection;
427 use forge_core::time::now_stamp;
428 use topcoat::router::request::Request;
429 use topcoat::router::{Body, Router, to_bytes};
430
431 use crate::auth::SESSION_COOKIE;
432
433 const CSRF: &str = "new-repo-test-csrf";
434
435 /// POSTs `/new` as alice with the given extra fields; returns the status and the label count
436 /// of the repo it created, or `None` when no repo was created.
437 async fn create_with(fields: &[(&str, &str)]) -> (u16, Option<usize>) {
438 let dir = tempfile::tempdir().expect("tempdir");
439 let state = crate::test_support::test_state(dir.path()).await;
440 let store: Store = state.store.clone();
441 let router: Router = crate::test_support::app_router(state);
442 let alice = store
443 .create_user("alice", None, "x", false)
444 .await
445 .expect("alice");
446 crate::test_support::create_session(&store, &alice.id, "alice-token", CSRF).await;
447 let cookie = format!("__Host-{SESSION_COOKIE}=alice-token");
448
449 let mut pairs = vec![("csrf", CSRF), ("name", "fresh")];
450 pairs.extend_from_slice(fields);
451 let body = form_urlencoded::Serializer::new(String::new())
452 .extend_pairs(pairs)
453 .finish();
454 let req = Request::builder()
455 .method("POST")
456 .uri("/new")
457 .header("cookie", cookie)
458 .header("content-type", "application/x-www-form-urlencoded")
459 .body(Body::from(body))
460 .expect("request");
461 let res = router.handle(req).await;
462 let status = res.status().as_u16();
463 let _ = to_bytes(res.into_body(), usize::MAX).await.expect("body");
464
465 let repo = store
466 .repo_by_owner_and_name("alice", "fresh")
467 .await
468 .expect("query");
469 let count = match repo {
470 Some(repo) => Some(store.list_labels(&repo.id).await.expect("labels").len()),
471 None => None,
472 };
473 (status, count)
474 }
475
476 #[tokio::test]
477 async fn new_repo_with_default_labels_checked_gets_nine() {
478 let (status, count) = create_with(&[("default_labels", "on")]).await;
479 assert_eq!(status, 303);
480 assert_eq!(count, Some(9));
481 }
482
483 #[tokio::test]
484 async fn new_repo_with_default_labels_unchecked_gets_none() {
485 let (status, count) = create_with(&[]).await;
486 assert_eq!(status, 303);
487 assert_eq!(count, Some(0));
488 }
489
490 /// POSTs `/new` as alice with the mirror fields and returns the status, the store and the repo
491 /// created (if any). The form's token is the shared CSRF value.
492 async fn submit_with_mirror(fields: &[(&str, &str)]) -> (u16, Store, Option<forge_core::Repo>) {
493 let dir = tempfile::tempdir().expect("tempdir");
494 let state = crate::test_support::test_state(dir.path()).await;
495 let store: Store = state.store.clone();
496 let router: Router = crate::test_support::app_router(state);
497 let alice = store
498 .create_user("alice", None, "x", false)
499 .await
500 .expect("alice");
501 crate::test_support::create_session(&store, &alice.id, "alice-token", CSRF).await;
502 let cookie = format!("__Host-{SESSION_COOKIE}=alice-token");
503
504 let mut pairs = vec![("csrf", CSRF), ("name", "mirrored")];
505 pairs.extend_from_slice(fields);
506 let body = form_urlencoded::Serializer::new(String::new())
507 .extend_pairs(pairs)
508 .finish();
509 let req = Request::builder()
510 .method("POST")
511 .uri("/new")
512 .header("cookie", cookie)
513 .header("content-type", "application/x-www-form-urlencoded")
514 .body(Body::from(body))
515 .expect("request");
516 let res = router.handle(req).await;
517 let status = res.status().as_u16();
518 let body = to_bytes(res.into_body(), usize::MAX).await.expect("body");
519 let text = String::from_utf8_lossy(&body).into_owned();
520 assert!(
521 !text.contains("hunter2-secret"),
522 "the mirror password is rendered"
523 );
524 let repo = store
525 .repo_by_owner_and_name("alice", "mirrored")
526 .await
527 .expect("query");
528 (status, store, repo)
529 }
530
531 #[tokio::test]
532 async fn new_repo_with_mirror_url_creates_a_pull_mirror() {
533 let (status, store, repo) = submit_with_mirror(&[
534 ("mirror_url", "https://example.com/upstream/proj.git"),
535 ("mirror_username", "bot"),
536 ("mirror_secret", "hunter2-secret"),
537 ("mirror_interval", "60"),
538 ])
539 .await;
540 assert_eq!(status, 303);
541 let repo = repo.expect("the repo is created");
542 let mirror = store
543 .pull_mirror(&repo.id)
544 .await
545 .expect("query")
546 .expect("a pull mirror");
547 assert_eq!(mirror.direction, MirrorDirection::Pull);
548 assert_eq!(mirror.url, "https://example.com/upstream/proj.git");
549 assert_eq!(mirror.username, "bot");
550 assert!(mirror.has_secret);
551 assert_eq!(mirror.interval_minutes, 60);
552 assert!(
553 mirror.next_sync_at <= now_stamp(),
554 "the first sync is due now"
555 );
556 }
557
558 #[tokio::test]
559 async fn new_repo_with_loopback_mirror_url_is_refused_and_creates_nothing() {
560 let (status, store, repo) =
561 submit_with_mirror(&[("mirror_url", "https://127.0.0.1/upstream/proj.git")]).await;
562 assert_eq!(status, 200, "the form is shown again with the error");
563 assert!(repo.is_none(), "no repo is created for a refused mirror");
564 assert!(
565 store
566 .repo_by_owner_and_name("alice", "mirrored")
567 .await
568 .expect("query")
569 .is_none()
570 );
571 }
572
573 #[tokio::test]
574 async fn new_repo_with_blank_mirror_url_creates_no_mirror() {
575 let (status, store, repo) = submit_with_mirror(&[("mirror_url", " ")]).await;
576 assert_eq!(status, 303);
577 let repo = repo.expect("the repo is created");
578 assert!(store.pull_mirror(&repo.id).await.expect("query").is_none());
579 }
580
581 #[tokio::test]
582 async fn the_form_is_in_turkish_for_a_turkish_viewer() {
583 let dir = tempfile::tempdir().expect("tempdir");
584 let state = crate::test_support::test_state(dir.path()).await;
585 let store: Store = state.store.clone();
586 let router: Router = crate::test_support::app_router(state);
587 let alice = store
588 .create_user("alice", None, "x", false)
589 .await
590 .expect("alice");
591 store
592 .set_ui_prefs(&alice.id, "auto", "tr")
593 .await
594 .expect("prefs");
595 crate::test_support::create_session(&store, &alice.id, "alice-token", CSRF).await;
596 let req = Request::builder()
597 .method("GET")
598 .uri("/new")
599 .header("cookie", format!("__Host-{SESSION_COOKIE}=alice-token"))
600 .body(Body::empty())
601 .expect("request");
602 let res = router.handle(req).await;
603 let status = res.status().as_u16();
604 let body = to_bytes(res.into_body(), usize::MAX).await.expect("body");
605 let text = String::from_utf8_lossy(&body).into_owned();
606 assert_eq!(status, 200, "{text}");
607 assert!(text.contains("Yeni depo"), "{text}");
608 assert!(text.contains("Depo oluştur"), "{text}");
609 }
610}