//! Page: create a repository (GET/POST /new). use forge_core::mirrors::{DEFAULT_INTERVAL_MINUTES, MirrorDirection, NewMirror}; use forge_core::names::validate_name; use forge_core::time::now_stamp; use forge_core::{AuditAction, User, Visibility}; use forge_git::repo::{init_bare, repo_path}; use serde::Deserialize; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::see_other; use topcoat::router::page; use topcoat::view::{View, component, view}; use crate::audit::{self, Actor, Target}; use crate::auth::{require_csrf, require_user}; use crate::i18n::community::{self as community, K as CommunityK}; use crate::i18n::misc::{self as g, K}; use crate::i18n::{self, Key, Lang}; use crate::pages::org_ctx::may_create_repo_in; use crate::pages::repo_mirrors::{Checked, check_form_in, redacted_url}; use crate::state::AppState; use crate::views::{alert, csrf_field, shell, user_facing_message}; const MAX_DESCRIPTION_CHARS: usize = 350; const DEFAULT_BRANCH: &str = "main"; #[derive(Deserialize)] struct NewRepoForm { #[serde(default)] csrf: String, #[serde(default)] name: String, #[serde(default)] description: String, #[serde(default)] visibility: String, #[serde(default)] default_branch: String, /// The checkbox sends `on` when checked and nothing when unchecked. #[serde(default)] default_labels: String, /// The optional "Mirror an existing repository" section. A blank URL means no mirror. #[serde(default)] mirror_url: String, #[serde(default)] mirror_username: String, #[serde(default)] mirror_secret: String, #[serde(default)] mirror_interval: String, /// The account that will own the repo: the viewer's own name, or an org they may create in. /// Blank means the viewer. #[serde(default)] owner: String, } /// The form's values, echoed back when validation fails. struct Draft { owner: String, name: String, description: String, private: bool, default_branch: String, default_labels: bool, mirror_url: String, mirror_username: String, mirror_interval: String, } impl Draft { fn fresh() -> Self { Draft { owner: String::new(), name: String::new(), description: String::new(), private: true, default_branch: DEFAULT_BRANCH.into(), default_labels: true, mirror_url: String::new(), mirror_username: String::new(), mirror_interval: DEFAULT_INTERVAL_MINUTES.to_string(), } } /// The submitted values. The mirror password is never echoed back. fn from_form(form: &NewRepoForm) -> Self { Draft { owner: form.owner.trim().to_owned(), name: form.name.clone(), description: form.description.clone(), private: form.visibility != "public", default_branch: form.default_branch.trim().to_owned(), default_labels: !form.default_labels.is_empty(), mirror_url: form.mirror_url.trim().to_owned(), mirror_username: form.mirror_username.trim().to_owned(), mirror_interval: form.mirror_interval.trim().to_owned(), } } } #[component] async fn new_repo_form( lang: Lang, csrf: &str, draft: Draft, error: Option, owners: Vec, ) -> Result { let branch = if draft.default_branch.is_empty() { DEFAULT_BRANCH.to_owned() } else { draft.default_branch }; let selected_owner = if draft.owner.is_empty() { owners.first().cloned().unwrap_or_default() } else { draft.owner.clone() }; let private = draft.private; let default_labels = draft.default_labels; // A mirror URL in the draft means the form was re-rendered after a validation error. let mirror_open = !draft.mirror_url.is_empty(); let draft_mirror_url = draft.mirror_url; let draft_mirror_username = draft.mirror_username; let draft_mirror_interval = draft.mirror_interval; Ok(view! { if let Some(message) = error { alert(message: message.as_str()) }
csrf_field(token: csrf)

(i18n::t(lang, Key::NewRepository))

(g::t(lang, K::VisibilityLegend))
(g::t(lang, K::MirrorLegend))

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

}) } /// The accounts the viewer may create a repo under: themselves, then the orgs where /// `may_create_repo_in` allows it. async fn owner_choices(state: &AppState, viewer: &User) -> Result> { let mut names = vec![viewer.username.clone()]; for (org, _) in state.store.orgs_for_user(&viewer.id).await? { if may_create_repo_in(state, &org.id, &viewer.id).await? { names.push(org.username); } } Ok(names) } #[page("/new")] async fn new_repo_page(cx: &Cx) -> Result { let viewer = require_user(cx).await?; let lang = i18n::lang(cx, Some(&viewer.0)); let owners = owner_choices(app_context::(cx), &viewer.0).await?; let csrf = viewer.1.csrf_token.clone(); Ok(view! { cx => 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)) }) } #[page(POST "/new")] async fn new_repo_submit(cx: &Cx, Form(form): Form) -> Result { let viewer = require_csrf(cx, &form.csrf).await?; let lang = i18n::lang(cx, Some(&viewer.0)); let outcome = create_repo(cx, &viewer.0, &form).await?; match outcome { Ok(location) => Err(see_other(location).into()), Err(message) => { let owners = owner_choices(app_context::(cx), &viewer.0).await?; let csrf = viewer.1.csrf_token.clone(); let draft = Draft::from_form(&form); Ok(view! { cx => 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)) }) } } } /// The account the new repo belongs to. Blank or the viewer's own name means the viewer. An org /// name is accepted only when the viewer may create repos under it. async fn repo_owner( state: &AppState, viewer: &User, lang: Lang, requested: &str, ) -> Result> { let requested = requested.trim(); if requested.is_empty() || requested.eq_ignore_ascii_case(&viewer.username) { return Ok(Ok(viewer.clone())); } let refused = g::t(lang, K::OwnerRefused).to_string(); let Some(org) = state.store.user_by_username(requested).await? else { return Ok(Err(refused)); }; if !org.is_org() || !may_create_repo_in(state, &org.id, &viewer.id).await? { return Ok(Err(refused)); } Ok(Ok(org)) } /// Validates the form and creates the repo. `Ok(Err(message))` is a user-facing rejection; /// the outer `Err` is an internal failure. async fn create_repo( cx: &Cx, owner: &User, form: &NewRepoForm, ) -> Result> { let state = app_context::(cx); let lang = i18n::lang(cx, Some(owner)); let name = match validate_name(&form.name) { Ok(name) => name, Err(e) => { return Ok(Err(user_facing_message(&e) .unwrap_or_else(|| g::t(lang, K::InvalidRepoName).to_owned()))); } }; if form.description.chars().count() > MAX_DESCRIPTION_CHARS { return Ok(Err(g::description_too_long(lang, MAX_DESCRIPTION_CHARS))); } let visibility = match form.visibility.as_str() { "public" => Visibility::Public, _ => Visibility::Private, }; let branch = match form.default_branch.trim() { "" => DEFAULT_BRANCH, other => other, }; let add_default_labels = !form.default_labels.is_empty(); let target = match repo_owner(state, owner, lang, &form.owner).await? { Ok(target) => target, Err(message) => return Ok(Err(message)), }; // The mirror is checked before anything is created, so a refused URL leaves no repo behind. let mirror = if form.mirror_url.trim().is_empty() { None } else { match check_form_in( lang, state, &form.mirror_url, &form.mirror_username, &form.mirror_secret, &form.mirror_interval, ) { Ok(checked) => Some(checked), Err(message) => return Ok(Err(message)), } }; let repo = match state .store .create_repo( &target.id, &name, form.description.trim(), visibility, branch, ) .await { Ok(repo) => repo, Err(e) => match user_facing_message(&e) { Some(message) => return Ok(Err(message)), None => return Err(e.into()), }, }; let path = repo_path(&state.config.data_dir, &repo.id)?; if let Err(e) = init_bare(&state.git, &path, branch).await { // TODO: delete the repository row so a failed init does not leave a dangling name. return Ok(Err(g::git_init_failed(lang, e))); } // The repo already exists at this point, so a label failure is logged rather than shown as an // error page for a repo that was in fact created. if add_default_labels && let Err(e) = state.store.create_default_labels(&repo.id).await { tracing::warn!(error = %e, repo_id = %repo.id, "new repo: could not add default labels"); } if let Some(checked) = &mirror { create_pull_mirror(cx, state, owner, &repo, checked).await?; } let full_name = format!("{}/{}", repo.owner_name, repo.name); audit::record( &state.store, Some(cx), Actor::User(owner), AuditAction::RepoCreated, Target { kind: "repo", id: &repo.id, name: &full_name, }, serde_json::Value::Null, ) .await; Ok(Ok(format!("/{}/{}", repo.owner_name, repo.name))) } /// Creates the pull mirror of a new repo and queues its first sync now. The repo is new and empty, /// so a store failure here is an internal error. async fn create_pull_mirror( cx: &Cx, state: &AppState, owner: &User, repo: &forge_core::Repo, checked: &Checked, ) -> Result<()> { let secret = (!checked.secret.is_empty()).then_some(checked.secret.as_bytes()); let mirror = state .store .create_mirror(&NewMirror { repo_id: &repo.id, direction: MirrorDirection::Pull, url: &checked.url, username: &checked.username, secret, interval_minutes: checked.interval, }) .await?; state .store .schedule_mirror(&mirror.id, &now_stamp()) .await?; state.mirror_waker.notify_one(); audit::record( &state.store, Some(cx), Actor::User(owner), AuditAction::MirrorCreated, Target { kind: "repo", id: &repo.id, name: &format!("{}/{}", repo.owner_name, repo.name), }, serde_json::json!({ "mirror_id": mirror.id, "direction": MirrorDirection::Pull.as_str(), "url": redacted_url(&mirror.url), }), ) .await; Ok(()) } #[cfg(test)] mod tests { use forge_core::Store; use forge_core::mirrors::MirrorDirection; use forge_core::time::now_stamp; use topcoat::router::request::Request; use topcoat::router::{Body, Router, to_bytes}; use crate::auth::SESSION_COOKIE; const CSRF: &str = "new-repo-test-csrf"; /// POSTs `/new` as alice with the given extra fields; returns the status and the label count /// of the repo it created, or `None` when no repo was created. async fn create_with(fields: &[(&str, &str)]) -> (u16, Option) { let dir = tempfile::tempdir().expect("tempdir"); let state = crate::test_support::test_state(dir.path()).await; let store: Store = state.store.clone(); let router: Router = crate::test_support::app_router(state); let alice = store .create_user("alice", None, "x", false) .await .expect("alice"); crate::test_support::create_session(&store, &alice.id, "alice-token", CSRF).await; let cookie = format!("__Host-{SESSION_COOKIE}=alice-token"); let mut pairs = vec![("csrf", CSRF), ("name", "fresh")]; pairs.extend_from_slice(fields); let body = form_urlencoded::Serializer::new(String::new()) .extend_pairs(pairs) .finish(); let req = Request::builder() .method("POST") .uri("/new") .header("cookie", cookie) .header("content-type", "application/x-www-form-urlencoded") .body(Body::from(body)) .expect("request"); let res = router.handle(req).await; let status = res.status().as_u16(); let _ = to_bytes(res.into_body(), usize::MAX).await.expect("body"); let repo = store .repo_by_owner_and_name("alice", "fresh") .await .expect("query"); let count = match repo { Some(repo) => Some(store.list_labels(&repo.id).await.expect("labels").len()), None => None, }; (status, count) } #[tokio::test] async fn new_repo_with_default_labels_checked_gets_nine() { let (status, count) = create_with(&[("default_labels", "on")]).await; assert_eq!(status, 303); assert_eq!(count, Some(9)); } #[tokio::test] async fn new_repo_with_default_labels_unchecked_gets_none() { let (status, count) = create_with(&[]).await; assert_eq!(status, 303); assert_eq!(count, Some(0)); } /// POSTs `/new` as alice with the mirror fields and returns the status, the store and the repo /// created (if any). The form's token is the shared CSRF value. async fn submit_with_mirror(fields: &[(&str, &str)]) -> (u16, Store, Option) { let dir = tempfile::tempdir().expect("tempdir"); let state = crate::test_support::test_state(dir.path()).await; let store: Store = state.store.clone(); let router: Router = crate::test_support::app_router(state); let alice = store .create_user("alice", None, "x", false) .await .expect("alice"); crate::test_support::create_session(&store, &alice.id, "alice-token", CSRF).await; let cookie = format!("__Host-{SESSION_COOKIE}=alice-token"); let mut pairs = vec![("csrf", CSRF), ("name", "mirrored")]; pairs.extend_from_slice(fields); let body = form_urlencoded::Serializer::new(String::new()) .extend_pairs(pairs) .finish(); let req = Request::builder() .method("POST") .uri("/new") .header("cookie", cookie) .header("content-type", "application/x-www-form-urlencoded") .body(Body::from(body)) .expect("request"); let res = router.handle(req).await; let status = res.status().as_u16(); let body = to_bytes(res.into_body(), usize::MAX).await.expect("body"); let text = String::from_utf8_lossy(&body).into_owned(); assert!( !text.contains("hunter2-secret"), "the mirror password is rendered" ); let repo = store .repo_by_owner_and_name("alice", "mirrored") .await .expect("query"); (status, store, repo) } #[tokio::test] async fn new_repo_with_mirror_url_creates_a_pull_mirror() { let (status, store, repo) = submit_with_mirror(&[ ("mirror_url", "https://example.com/upstream/proj.git"), ("mirror_username", "bot"), ("mirror_secret", "hunter2-secret"), ("mirror_interval", "60"), ]) .await; assert_eq!(status, 303); let repo = repo.expect("the repo is created"); let mirror = store .pull_mirror(&repo.id) .await .expect("query") .expect("a pull mirror"); assert_eq!(mirror.direction, MirrorDirection::Pull); assert_eq!(mirror.url, "https://example.com/upstream/proj.git"); assert_eq!(mirror.username, "bot"); assert!(mirror.has_secret); assert_eq!(mirror.interval_minutes, 60); assert!( mirror.next_sync_at <= now_stamp(), "the first sync is due now" ); } #[tokio::test] async fn new_repo_with_loopback_mirror_url_is_refused_and_creates_nothing() { let (status, store, repo) = submit_with_mirror(&[("mirror_url", "https://127.0.0.1/upstream/proj.git")]).await; assert_eq!(status, 200, "the form is shown again with the error"); assert!(repo.is_none(), "no repo is created for a refused mirror"); assert!( store .repo_by_owner_and_name("alice", "mirrored") .await .expect("query") .is_none() ); } #[tokio::test] async fn new_repo_with_blank_mirror_url_creates_no_mirror() { let (status, store, repo) = submit_with_mirror(&[("mirror_url", " ")]).await; assert_eq!(status, 303); let repo = repo.expect("the repo is created"); assert!(store.pull_mirror(&repo.id).await.expect("query").is_none()); } #[tokio::test] async fn the_form_is_in_turkish_for_a_turkish_viewer() { let dir = tempfile::tempdir().expect("tempdir"); let state = crate::test_support::test_state(dir.path()).await; let store: Store = state.store.clone(); let router: Router = crate::test_support::app_router(state); let alice = store .create_user("alice", None, "x", false) .await .expect("alice"); store .set_ui_prefs(&alice.id, "auto", "tr") .await .expect("prefs"); crate::test_support::create_session(&store, &alice.id, "alice-token", CSRF).await; let req = Request::builder() .method("GET") .uri("/new") .header("cookie", format!("__Host-{SESSION_COOKIE}=alice-token")) .body(Body::empty()) .expect("request"); let res = router.handle(req).await; let status = res.status().as_u16(); let body = to_bytes(res.into_body(), usize::MAX).await.expect("body"); let text = String::from_utf8_lossy(&body).into_owned(); assert_eq!(status, 200, "{text}"); assert!(text.contains("Yeni depo"), "{text}"); assert!(text.contains("Depo oluştur"), "{text}"); } }