forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
46.6 KiB
1//! Page: repo settings (`/{owner}/{repo}/settings`). The owner, an instance admin and collaborators
2//! with the Admin role get the page. It holds the General section (description, default branch,
3//! visibility, archive), the branch protection rules (Admin) and the danger zone (Delete: owner or
4//! instance admin only). Deleting moves the bare repo into `data_dir/trash` and removes its database
5//! rows in one transaction. The repo is never erased from disk, so an admin can recover it from the
6//! trash.
7
8use std::path::Path;
9
10use forge_core::protection::{
11 BranchRule, RuleFlags, pattern_matches, validate_pattern, validate_required_approvals,
12};
13use forge_core::store::MAX_DESCRIPTION_CHARS;
14use forge_core::{AuditAction, Repo as RepoRow, Visibility};
15use forge_git::browse::RefKind;
16use forge_git::command::{GitCommand, GitEnv};
17use forge_git::repo::{
18 move_to_trash, move_wiki_to_trash, repo_dir_missing, restore_from_trash,
19 restore_wiki_from_trash,
20};
21use serde::Deserialize;
22use topcoat::Result;
23use topcoat::context::{Cx, app_context};
24use topcoat::cookie::{Cookie, Cookies, SameSite, cookies};
25use topcoat::router::content::Form;
26use topcoat::router::error::{SeeOther, forbidden, see_other};
27use topcoat::router::{page, path_param, route};
28use topcoat::view::{View, component, view};
29
30use crate::audit::{self, Actor, Target};
31use crate::auth::{require_csrf, require_user};
32use crate::i18n::repo_admin::{self as g, K};
33use crate::i18n::{self, Lang};
34use crate::pages::discussion_settings::discussions_card;
35use crate::pages::refs::load_refs;
36use crate::pages::repo::repo_head;
37use crate::pages::repo_ctx::{RepoCtx, RepoHead, load_repo};
38use crate::state::AppState;
39use crate::views::{alert, shell};
40
41path_param!(owner);
42path_param!(repo);
43
44/// Cookie that carries a one-shot notice (the deleted repo's `owner/name`) to the next home page.
45pub const NOTICE_COOKIE: &str = "forge_notice";
46
47/// Cookie that carries a one-shot settings notice to the next settings page. Its value is one of
48/// the fixed words in `SETTINGS_NOTICES`, never user text.
49const SETTINGS_NOTICE_COOKIE: &str = "forge_settings_notice";
50
51/// Notice words, the text each one shows, and whether it is an error (shown as an alert).
52const SETTINGS_NOTICES: [(&str, K, bool); 11] = [
53 ("saved", K::BranchRuleSaved, false),
54 ("removed", K::BranchRuleRemoved, false),
55 ("general", K::GeneralSaved, false),
56 ("visibility", K::VisibilityChanged, false),
57 ("archived", K::RepoArchived, false),
58 ("unarchived", K::RepoUnarchived, false),
59 ("description_long", K::DescriptionLong, true),
60 ("branch_missing", K::BranchMissing, true),
61 ("git_failed", K::DefaultBranchFailed, true),
62 ("understand", K::UnderstandNeeded, true),
63 ("bad_form", K::BadForm, true),
64];
65
66/// Longest accepted `owner/name` in the notice cookie.
67const MAX_NOTICE_CHARS: usize = 256;
68
69#[derive(Deserialize)]
70struct DeleteForm {
71 #[serde(default)]
72 csrf: String,
73 #[serde(default)]
74 confirm: String,
75}
76
77/// The General form: description and default branch, saved together.
78#[derive(Deserialize)]
79struct GeneralForm {
80 #[serde(default)]
81 csrf: String,
82 #[serde(default)]
83 description: String,
84 /// Empty when the repo has no branches yet; the current default branch is then kept.
85 #[serde(default)]
86 default_branch: String,
87}
88
89/// The visibility form. An unchecked "I understand" box is absent from the body.
90#[derive(Deserialize)]
91struct VisibilityForm {
92 #[serde(default)]
93 csrf: String,
94 #[serde(default)]
95 visibility: String,
96 #[serde(default)]
97 understand: Option<String>,
98}
99
100#[derive(Deserialize)]
101struct ArchiveForm {
102 #[serde(default)]
103 csrf: String,
104 /// `1` archives the repo, `0` unarchives it.
105 #[serde(default)]
106 archived: String,
107}
108
109/// The add/update rule form. An unchecked box is absent from the body, so `Option` is `None`.
110#[derive(Deserialize)]
111struct BranchForm {
112 #[serde(default)]
113 csrf: String,
114 #[serde(default)]
115 pattern: String,
116 #[serde(default)]
117 allow_force_push: Option<String>,
118 #[serde(default)]
119 allow_deletion: Option<String>,
120 /// Text from the number field; blank means 0.
121 #[serde(default)]
122 required_approvals: String,
123 #[serde(default)]
124 block_on_changes_requested: Option<String>,
125 #[serde(default)]
126 require_up_to_date: Option<String>,
127 #[serde(default)]
128 restrict_push: Option<String>,
129}
130
131#[derive(Deserialize)]
132struct BranchDeleteForm {
133 #[serde(default)]
134 csrf: String,
135 #[serde(default)]
136 rule_id: String,
137}
138
139/// Sets the notice shown on the next home page view.
140pub fn set_notice(cx: &Cx, title: &str) {
141 cookies(cx).add(
142 Cookie::build((NOTICE_COOKIE, title.to_owned()))
143 .path("/")
144 .http_only(true)
145 .same_site(SameSite::Lax)
146 .build(),
147 );
148}
149
150/// Reads and clears the notice cookie. Anything that is not a plain `owner/name` is ignored.
151pub fn take_notice(cx: &Cx) -> Option<String> {
152 let jar = cookies(cx);
153 let value = jar.get(NOTICE_COOKIE)?.value().to_owned();
154 jar.remove(Cookie::build((NOTICE_COOKIE, "")).path("/"));
155 let plain = !value.is_empty()
156 && value.chars().count() <= MAX_NOTICE_CHARS
157 && value
158 .chars()
159 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/'));
160 plain.then_some(value)
161}
162
163/// Sets the one-line notice shown on the next settings page view.
164fn set_settings_notice(cx: &Cx, word: &'static str) {
165 cookies(cx).add(
166 Cookie::build((SETTINGS_NOTICE_COOKIE, word))
167 .path("/")
168 .http_only(true)
169 .same_site(SameSite::Lax)
170 .build(),
171 );
172}
173
174/// Reads and clears the settings notice cookie. Only the fixed words are shown. The flag says
175/// whether the notice is an error.
176fn take_settings_notice(cx: &Cx) -> Option<(K, bool)> {
177 let jar = cookies(cx);
178 let value = jar.get(SETTINGS_NOTICE_COOKIE)?.value().to_owned();
179 jar.remove(Cookie::build((SETTINGS_NOTICE_COOKIE, "")).path("/"));
180 SETTINGS_NOTICES
181 .iter()
182 .find(|(word, _, _)| *word == value)
183 .map(|(_, key, is_error)| (*key, *is_error))
184}
185
186/// Redirect back to the General section, which shows the notice set just before.
187fn back_to_settings(title: &str) -> SeeOther {
188 see_other(format!("/{title}/settings"))
189}
190
191/// Drops control characters (so the text is one line) and surrounding spaces.
192fn clean_description(raw: &str) -> String {
193 raw.chars()
194 .filter(|c| !c.is_control())
195 .collect::<String>()
196 .trim()
197 .to_owned()
198}
199
200/// Points git HEAD of the bare repo at `branch`. The caller has checked that the branch exists.
201/// The `refs/heads/` prefix means the value can never be read as an option.
202async fn point_head(env: &GitEnv, repo_path: &Path, branch: &str) -> forge_git::Result<()> {
203 GitCommand::new(env, "symbolic-ref")
204 .git_dir(repo_path)
205 .arg("HEAD")
206 .user_arg(&format!("refs/heads/{branch}"))?
207 .status()
208 .await
209}
210
211/// Branch names of the repo. Empty for an empty repo.
212async fn load_branch_names(ctx: &RepoCtx) -> Result<Vec<String>> {
213 let refs = load_refs(ctx.path.clone()).await?;
214 Ok(refs
215 .into_iter()
216 .filter(|r| r.kind == RefKind::Branch)
217 .map(|r| r.name)
218 .collect())
219}
220
221/// The one-line notice on the home page after a delete.
222#[component]
223pub async fn notice_banner_for(lang: Lang, title: Option<String>) -> Result<impl View> {
224 Ok(view! {
225 if let Some(title) = title {
226 <section class="card notice" role="status">
227 (g::t(lang, K::RepoDeletedBefore))<code>(title)</code>(g::t(lang, K::RepoDeletedAfter))
228 </section>
229 }
230 })
231}
232
233/// The danger zone: explains what deleting does and holds the confirmation form.
234#[component]
235async fn danger_zone(
236 lang: Lang,
237 csrf: String,
238 title: String,
239 error: Option<String>,
240) -> Result<impl View> {
241 let action = format!("/{title}/settings/delete");
242 Ok(view! {
243 <section class="card">
244 <h2>(g::t(lang, K::DangerZone))</h2>
245 <p>(g::t(lang, K::DeleteRepoHelp))</p>
246 <p class="muted small">(g::t(lang, K::DeleteTrashHelp))</p>
247 if let Some(message) = error {
248 alert(message: message.as_str())
249 }
250 <form method="post" action=(action) class="stack">
251 <input type="hidden" name="csrf" value=(csrf)>
252 <label>
253 <span>(g::t(lang, K::TypeToConfirmBefore))<code>(title.clone())</code>(g::t(lang, K::TypeToConfirmAfter))</span>
254 <input type="text" name="confirm" autocomplete="off" spellcheck="false" required=(true)>
255 </label>
256 <button type="submit" class="danger">(g::t(lang, K::DeleteRepo))</button>
257 </form>
258 </section>
259 })
260}
261
262/// Sub-navigation for the settings pages. `current` names the page shown (`general` or `webhooks`)
263/// and gets `aria-current`.
264#[component]
265pub async fn settings_nav_for(
266 lang: Lang,
267 title: String,
268 current: &'static str,
269) -> Result<impl View> {
270 let general = format!("/{title}/settings");
271 let collaborators = format!("/{title}/settings/collaborators");
272 let rename = format!("/{title}/settings/rename");
273 let hooks = format!("/{title}/settings/hooks");
274 let mirrors = format!("/{title}/settings/mirrors");
275 let secrets = format!("/{title}/settings/secrets");
276 let general_on = current == "general";
277 let hooks_on = current == "webhooks";
278 let mirrors_on = current == "mirrors";
279 let secrets_on = current == "secrets";
280 Ok(view! {
281 <nav class="settings-nav" aria-label=(g::t(lang, K::SettingsNavLabel))>
282 if general_on {
283 <a href=(general) aria-current="page">(g::t(lang, K::General))</a>
284 } else {
285 <a href=(general)>(g::t(lang, K::General))</a>
286 }
287 <a href=(collaborators)>(g::t(lang, K::Collaborators))</a>
288 <a href=(rename)>(g::t(lang, K::RenameTransferNav))</a>
289 if hooks_on {
290 <a href=(hooks) aria-current="page">(g::t(lang, K::Webhooks))</a>
291 } else {
292 <a href=(hooks)>(g::t(lang, K::Webhooks))</a>
293 }
294 if mirrors_on {
295 <a href=(mirrors) aria-current="page">(g::t(lang, K::Mirrors))</a>
296 } else {
297 <a href=(mirrors)>(g::t(lang, K::Mirrors))</a>
298 }
299 if secrets_on {
300 <a href=(secrets) aria-current="page">(g::t(lang, K::Secrets))</a>
301 } else {
302 <a href=(secrets)>(g::t(lang, K::Secrets))</a>
303 }
304 </nav>
305 })
306}
307
308/// The General section: description and default branch (one form), then visibility and archive.
309/// Each form carries its own CSRF token. An empty repo shows "No branches yet" and no selector.
310#[component]
311async fn general_section(
312 lang: Lang,
313 title: String,
314 csrf: String,
315 description: String,
316 default_branch: String,
317 branches: Vec<String>,
318 visibility: Visibility,
319 archived: bool,
320) -> Result<impl View> {
321 let general_action = format!("/{title}/settings/general");
322 let visibility_action = format!("/{title}/settings/visibility");
323 let archive_action = format!("/{title}/settings/archive");
324 let is_public = visibility == Visibility::Public;
325 let private_checked = !is_public;
326 let archive_value = if archived { "0" } else { "1" };
327 let archive_label = if archived {
328 g::t(lang, K::UnarchiveRepo)
329 } else {
330 g::t(lang, K::ArchiveRepo)
331 };
332 let options: Vec<(String, bool)> = branches
333 .into_iter()
334 .map(|b| {
335 let current = b == default_branch;
336 (b, current)
337 })
338 .collect();
339 Ok(view! {
340 <section class="card">
341 <h2>(g::t(lang, K::General))</h2>
342 <form method="post" action=(general_action) class="stack">
343 <input type="hidden" name="csrf" value=(csrf.clone())>
344 <label>
345 <span>(g::t(lang, K::Description))</span>
346 <input type="text" name="description" value=(description) maxlength="350" autocomplete="off">
347 </label>
348 <p class="muted small">(g::t(lang, K::DescriptionHelp))</p>
349 if options.is_empty() {
350 <p class="muted">(g::t(lang, K::NoBranchesYet))</p>
351 } else {
352 <label>
353 <span>(g::t(lang, K::DefaultBranch))</span>
354 <select name="default_branch">
355 for option in options.iter() {
356 <option value=(option.0.as_str()) selected=(option.1)>(option.0.as_str())</option>
357 }
358 </select>
359 </label>
360 }
361 <div>
362 <button type="submit" class="primary">(g::t(lang, K::SaveGeneral))</button>
363 </div>
364 </form>
365
366 <h3>(g::t(lang, K::VisibilityHeading))</h3>
367 <form method="post" action=(visibility_action) class="stack">
368 <input type="hidden" name="csrf" value=(csrf.clone())>
369 <label class="inline">
370 <input type="radio" name="visibility" value="public" checked=(is_public)>
371 <span>(g::t(lang, K::PublicOption))</span>
372 </label>
373 <label class="inline">
374 <input type="radio" name="visibility" value="private" checked=(private_checked)>
375 <span>(g::t(lang, K::PrivateOption))</span>
376 </label>
377 <label class="inline">
378 <input type="checkbox" name="understand" value="on">
379 <span>(g::t(lang, K::IUnderstand))</span>
380 </label>
381 <p class="muted small">(g::t(lang, K::VisibilityHelp))</p>
382 <div>
383 <button type="submit">(g::t(lang, K::ChangeVisibility))</button>
384 </div>
385 </form>
386
387 <h3>(g::t(lang, K::ArchiveHeading))</h3>
388 if archived {
389 <p class="muted small">(g::t(lang, K::ArchivedHelp))</p>
390 } else {
391 <p class="muted small">(g::t(lang, K::ArchiveHelp))</p>
392 }
393 <form method="post" action=(archive_action) class="stack">
394 <input type="hidden" name="csrf" value=(csrf.clone())>
395 <input type="hidden" name="archived" value=(archive_value)>
396 <div>
397 <button type="submit">(archive_label)</button>
398 </div>
399 </form>
400 </section>
401 })
402}
403
404/// Reads the rule settings from the form. Any problem is the message to show next to the form.
405/// The message is for a `required_approvals` value outside 0..=20 or not a whole number.
406fn parse_rule_flags(lang: Lang, form: &BranchForm) -> std::result::Result<RuleFlags, String> {
407 let approvals_msg = || g::t(lang, K::ApprovalsRange).to_owned();
408 let raw = form.required_approvals.trim();
409 let required_approvals = if raw.is_empty() {
410 0
411 } else {
412 raw.parse::<u32>().map_err(|_| approvals_msg())?
413 };
414 validate_required_approvals(required_approvals).map_err(|_| approvals_msg())?;
415 Ok(RuleFlags {
416 allow_force_push: form.allow_force_push.is_some(),
417 allow_deletion: form.allow_deletion.is_some(),
418 required_approvals,
419 block_on_changes_requested: form.block_on_changes_requested.is_some(),
420 require_up_to_date: form.require_up_to_date.is_some(),
421 restrict_push: form.restrict_push.is_some(),
422 })
423}
424
425/// A short list of the merge and push rules a rule sets, or "none".
426fn rule_summary(lang: Lang, rule: &BranchRule) -> String {
427 let mut parts = Vec::new();
428 if rule.required_approvals > 0 {
429 parts.push(g::approvals_count(lang, rule.required_approvals));
430 }
431 if rule.block_on_changes_requested {
432 parts.push(g::t(lang, K::RuleChangesBlock).to_owned());
433 }
434 if rule.require_up_to_date {
435 parts.push(g::t(lang, K::RuleUpToDate).to_owned());
436 }
437 if rule.restrict_push {
438 parts.push(g::t(lang, K::RuleRestrictPush).to_owned());
439 }
440 if parts.is_empty() {
441 g::t(lang, K::NoRule).to_owned()
442 } else {
443 parts.join("; ")
444 }
445}
446
447/// The rules table. A muted built-in row stands in for the default branch when no rule matches it.
448#[component]
449async fn branch_rules_table(
450 lang: Lang,
451 rules: Vec<BranchRule>,
452 default_branch: String,
453 builtin_row: bool,
454 delete_action: String,
455 csrf: String,
456) -> Result<impl View> {
457 Ok(view! {
458 <div class="table-wrap">
459 <table class="table branch-rules">
460 <thead>
461 <tr>
462 <th>(g::t(lang, K::PatternColumn))</th>
463 <th>(g::t(lang, K::ForcePushAllowed))</th>
464 <th>(g::t(lang, K::DeletionAllowed))</th>
465 <th>(g::t(lang, K::MergePushRules))</th>
466 <th><span class="muted small">(g::t(lang, K::Remove))</span></th>
467 </tr>
468 </thead>
469 <tbody>
470 for rule in rules.iter() {
471 <tr>
472 <td><code>(rule.pattern.as_str())</code></td>
473 <td>(g::yes_no(lang, rule.allow_force_push))</td>
474 <td>(g::yes_no(lang, rule.allow_deletion))</td>
475 <td>(rule_summary(lang, rule))</td>
476 <td>
477 <form method="post" action=(delete_action.as_str()) class="remove-form">
478 <input type="hidden" name="csrf" value=(csrf.as_str())>
479 <input type="hidden" name="rule_id" value=(rule.id.as_str())>
480 <button type="submit">(g::t(lang, K::Remove))</button>
481 </form>
482 </td>
483 </tr>
484 }
485 if builtin_row {
486 <tr class="muted">
487 <td colspan="5"><code>(default_branch.as_str())</code>(g::t(lang, K::BuiltinRowText))</td>
488 </tr>
489 }
490 </tbody>
491 </table>
492 </div>
493 })
494}
495
496/// The add/update form. Saving an existing pattern updates its rule.
497#[component]
498async fn branch_rule_form(
499 lang: Lang,
500 csrf: String,
501 save_action: String,
502 pattern: String,
503 allow_force_push: bool,
504 allow_deletion: bool,
505 required_approvals: String,
506 block_on_changes_requested: bool,
507 require_up_to_date: bool,
508 restrict_push: bool,
509 error: Option<String>,
510) -> Result<impl View> {
511 Ok(view! {
512 <form method="post" action=(save_action.as_str()) class="stack branch-form">
513 if let Some(message) = error {
514 alert(message: message.as_str())
515 }
516 <input type="hidden" name="csrf" value=(csrf.as_str())>
517 <label>
518 <span>(g::t(lang, K::BranchPattern))</span>
519 <input type="text" name="pattern" value=(pattern.as_str()) autocomplete="off" spellcheck="false" required=(true)>
520 </label>
521 <p class="muted small">(g::t(lang, K::PatternHelpA))<code>"*"</code>(g::t(lang, K::PatternHelpB))<code>"**"</code>(g::t(lang, K::PatternHelpC))<code>"/"</code>(g::t(lang, K::PatternHelpD))</p>
522 <label class="inline">
523 <input type="checkbox" name="allow_force_push" value="on" checked=(allow_force_push)>
524 <span>(g::t(lang, K::AllowForcePush))</span>
525 </label>
526 <label class="inline">
527 <input type="checkbox" name="allow_deletion" value="on" checked=(allow_deletion)>
528 <span>(g::t(lang, K::AllowDeletion))</span>
529 </label>
530 <label>
531 <span>(g::t(lang, K::RequiredApprovals))</span>
532 <input type="number" name="required_approvals" min="0" max="20" step="1" inputmode="numeric" value=(required_approvals.as_str())>
533 </label>
534 <p class="muted small">(g::t(lang, K::ApprovalsHelp))</p>
535 <label class="inline">
536 <input type="checkbox" name="block_on_changes_requested" value="on" checked=(block_on_changes_requested)>
537 <span>(g::t(lang, K::BlockWhileChanges))</span>
538 </label>
539 <label class="inline">
540 <input type="checkbox" name="require_up_to_date" value="on" checked=(require_up_to_date)>
541 <span>(g::t(lang, K::RequireUpToDate))</span>
542 </label>
543 <label class="inline">
544 <input type="checkbox" name="restrict_push" value="on" checked=(restrict_push)>
545 <span>(g::t(lang, K::RestrictPush))</span>
546 </label>
547 <div>
548 <button type="submit">(g::t(lang, K::SaveRule))</button>
549 </div>
550 </form>
551 })
552}
553
554/// The form values to show: the pattern and checkboxes, plus any validation error.
555struct RuleDraft {
556 pattern: String,
557 allow_force_push: bool,
558 allow_deletion: bool,
559 required_approvals: String,
560 block_on_changes_requested: bool,
561 require_up_to_date: bool,
562 restrict_push: bool,
563 error: Option<String>,
564}
565
566impl RuleDraft {
567 /// The default values for the add form, with the default branch as the pattern.
568 fn for_default_branch(default_branch: &str) -> Self {
569 Self {
570 pattern: default_branch.to_owned(),
571 allow_force_push: false,
572 allow_deletion: false,
573 required_approvals: "0".to_owned(),
574 block_on_changes_requested: false,
575 require_up_to_date: false,
576 restrict_push: false,
577 error: None,
578 }
579 }
580}
581
582/// Everything the settings page shows besides the repo header.
583struct SettingsView {
584 rules: Vec<BranchRule>,
585 /// A settings notice: its text and whether it is an error.
586 notice: Option<(K, bool)>,
587 draft: RuleDraft,
588 delete_error: Option<String>,
589}
590
591/// The settings body: the repo header tabs with Settings active, the sub-navigation, the General
592/// section, the branch protection section, then the danger zone when the viewer may delete the repo.
593#[component]
594async fn settings_body(
595 lang: Lang,
596 head: RepoHead,
597 default_branch: String,
598 archived: bool,
599 branches: Vec<String>,
600 title: String,
601 csrf: String,
602 can_delete: bool,
603 page: SettingsView,
604) -> Result<impl View> {
605 let builtin_row = !page
606 .rules
607 .iter()
608 .any(|rule| pattern_matches(&rule.pattern, &default_branch));
609 let rules_action = format!("/{title}/settings/branches");
610 let delete_action = format!("/{title}/settings/branches/delete");
611 let SettingsView {
612 rules,
613 notice,
614 draft,
615 delete_error,
616 } = page;
617 let (description, visibility) = (head.description.clone(), head.visibility);
618 let discussions_on = head.discussions;
619 let notice = notice.map(|(key, is_error)| (g::t(lang, key), is_error));
620 Ok(view! {
621 repo_head(head: head)
622 settings_nav_for(lang: lang, title: title.clone(), current: "general")
623 if let Some((text, is_error)) = notice {
624 if is_error {
625 alert(message: text)
626 } else {
627 <section class="card notice" role="status">(text)</section>
628 }
629 }
630 general_section(lang: lang, title: title.clone(), csrf: csrf.clone(), description: description,
631 default_branch: default_branch.clone(), branches: branches,
632 visibility: visibility, archived: archived)
633 <section class="card">
634 <h2>(g::t(lang, K::BranchProtection))</h2>
635 <p class="muted small">(g::t(lang, K::BranchProtectionHelp))</p>
636 branch_rules_table(lang: lang, rules: rules, default_branch: default_branch.clone(),
637 builtin_row: builtin_row, delete_action: delete_action, csrf: csrf.clone())
638 <h3>(g::t(lang, K::AddOrUpdateRule))</h3>
639 branch_rule_form(lang: lang, csrf: csrf.clone(), save_action: rules_action, pattern: draft.pattern,
640 allow_force_push: draft.allow_force_push, allow_deletion: draft.allow_deletion,
641 required_approvals: draft.required_approvals,
642 block_on_changes_requested: draft.block_on_changes_requested,
643 require_up_to_date: draft.require_up_to_date, restrict_push: draft.restrict_push,
644 error: draft.error)
645 </section>
646 discussions_card(lang: lang, action: format!("/{title}/settings/discussions"), csrf: csrf.clone(), enabled: discussions_on)
647 if can_delete {
648 danger_zone(lang: lang, csrf: csrf, title: title, error: delete_error)
649 }
650 })
651}
652
653/// The repo fields the settings page shows, read once from the repo context.
654struct SettingsFrame {
655 page_title: String,
656 default_branch: String,
657 archived: bool,
658 title: String,
659 can_delete: bool,
660 head: RepoHead,
661}
662
663impl SettingsFrame {
664 async fn new(ctx: &RepoCtx, state: &AppState, lang: Lang) -> Result<Self> {
665 let title = format!("{}/{}", ctx.repo.owner_name, ctx.repo.name);
666 Ok(Self {
667 page_title: g::settings_page_title(lang, &title),
668 default_branch: ctx.repo.default_branch.clone(),
669 archived: ctx.repo.archived,
670 title,
671 can_delete: ctx.can_delete(),
672 head: ctx.head(state, "settings").await?,
673 })
674 }
675}
676
677/// Gets the settings page. Hidden repos are 404 (checked in `load_repo`), anonymous viewers of
678/// visible repos go to login, and signed-in viewers who may not manage the repo get 403.
679#[page("/{owner}/{repo}/settings")]
680async fn settings_page(cx: &Cx) -> Result<impl View> {
681 let owner = path_param::<Owner>(cx);
682 let name = path_param::<Repo>(cx);
683 let ctx = load_repo(cx, owner, name).await?;
684 let (user, session) = require_user(cx).await?;
685 if !ctx.can_admin() {
686 return Err(forbidden().into());
687 }
688 let lang = i18n::lang(cx, Some(&user));
689 let rules = app_context::<AppState>(cx)
690 .store
691 .list_branch_rules(&ctx.repo.id)
692 .await?;
693 let page = SettingsView {
694 rules,
695 notice: take_settings_notice(cx),
696 draft: RuleDraft::for_default_branch(&ctx.repo.default_branch),
697 delete_error: None,
698 };
699 let csrf = session.csrf_token.clone();
700 let frame = SettingsFrame::new(&ctx, app_context::<AppState>(cx), lang).await?;
701 let branches = load_branch_names(&ctx).await?;
702 Ok(view! {
703 shell(title: frame.page_title.as_str(), viewer: ctx.viewer.as_ref(),
704 settings_body(lang: lang, head: frame.head, default_branch: frame.default_branch, archived: frame.archived,
705 branches: branches, title: frame.title, csrf: csrf,
706 can_delete: frame.can_delete, page: page))
707 })
708}
709
710/// Saves the description and the default branch together. The form is checked for CSRF and
711/// `Action::Admin` first. The branch must be an existing branch: git HEAD is pointed at it first,
712/// and the database changes only when that succeeds. A failed database write puts HEAD back.
713#[route(POST "/{owner}/{repo}/settings/general")]
714async fn settings_general_save(cx: &Cx, Form(form): Form<GeneralForm>) -> Result<SeeOther> {
715 let owner = path_param::<Owner>(cx);
716 let name = path_param::<Repo>(cx);
717 let ctx = load_repo(cx, owner, name).await?;
718 let (user, _) = require_csrf(cx, &form.csrf).await?;
719 if !ctx.can_admin() {
720 return Err(forbidden().into());
721 }
722 let title = format!("{}/{}", ctx.repo.owner_name, ctx.repo.name);
723 let state = app_context::<AppState>(cx);
724
725 let description = clean_description(&form.description);
726 if description.chars().count() > MAX_DESCRIPTION_CHARS {
727 set_settings_notice(cx, "description_long");
728 return Ok(back_to_settings(&title));
729 }
730
731 let requested = form.default_branch.trim();
732 let mut default_branch = ctx.repo.default_branch.clone();
733 if !requested.is_empty() {
734 let branches = load_branch_names(&ctx).await?;
735 if !branches.iter().any(|b| b == requested) {
736 set_settings_notice(cx, "branch_missing");
737 return Ok(back_to_settings(&title));
738 }
739 if let Err(e) = point_head(&state.git, &ctx.path, requested).await {
740 tracing::warn!(
741 repo_id = %ctx.repo.id,
742 branch = requested,
743 error = %e,
744 "symbolic-ref failed; default branch unchanged"
745 );
746 set_settings_notice(cx, "git_failed");
747 return Ok(back_to_settings(&title));
748 }
749 default_branch = requested.to_owned();
750 }
751
752 if let Err(e) = state
753 .store
754 .update_repo_general(&ctx.repo.id, &description, &default_branch)
755 .await
756 {
757 if default_branch != ctx.repo.default_branch
758 && let Err(revert) = point_head(&state.git, &ctx.path, &ctx.repo.default_branch).await
759 {
760 tracing::error!(
761 repo_id = %ctx.repo.id,
762 error = %revert,
763 "could not restore git HEAD after a failed save; check HEAD by hand"
764 );
765 }
766 return Err(e.into());
767 }
768 tracing::info!(
769 actor_id = %user.id,
770 repo_id = %ctx.repo.id,
771 repo = %title,
772 default_branch = %default_branch,
773 "general settings saved"
774 );
775 set_settings_notice(cx, "general");
776 Ok(back_to_settings(&title))
777}
778
779/// Changes the visibility. Changing it needs the "I understand" box. Picking the current value
780/// changes nothing.
781#[route(POST "/{owner}/{repo}/settings/visibility")]
782async fn settings_visibility_save(cx: &Cx, Form(form): Form<VisibilityForm>) -> Result<SeeOther> {
783 let owner = path_param::<Owner>(cx);
784 let name = path_param::<Repo>(cx);
785 let ctx = load_repo(cx, owner, name).await?;
786 let (user, _) = require_csrf(cx, &form.csrf).await?;
787 if !ctx.can_admin() {
788 return Err(forbidden().into());
789 }
790 let title = format!("{}/{}", ctx.repo.owner_name, ctx.repo.name);
791 let Ok(target) = form.visibility.parse::<Visibility>() else {
792 set_settings_notice(cx, "bad_form");
793 return Ok(back_to_settings(&title));
794 };
795 if target != ctx.repo.visibility {
796 if form.understand.is_none() {
797 set_settings_notice(cx, "understand");
798 return Ok(back_to_settings(&title));
799 }
800 let state = app_context::<AppState>(cx);
801 state
802 .store
803 .set_repo_visibility(&ctx.repo.id, target)
804 .await?;
805 tracing::info!(
806 actor_id = %user.id,
807 repo_id = %ctx.repo.id,
808 repo = %title,
809 visibility = target.as_str(),
810 "repo visibility changed"
811 );
812 audit::record(
813 &state.store,
814 Some(cx),
815 Actor::User(&user),
816 AuditAction::RepoVisibilityChanged,
817 Target {
818 kind: "repo",
819 id: &ctx.repo.id,
820 name: &title,
821 },
822 serde_json::json!({ "to": target.as_str() }),
823 )
824 .await;
825 set_settings_notice(cx, "visibility");
826 }
827 Ok(back_to_settings(&title))
828}
829
830/// Archives (`archived=1`) or unarchives (`archived=0`) the repo. Archiving is an Admin action, and
831/// the archived flag does not block Admin.
832#[route(POST "/{owner}/{repo}/settings/archive")]
833async fn settings_archive_save(cx: &Cx, Form(form): Form<ArchiveForm>) -> Result<SeeOther> {
834 let owner = path_param::<Owner>(cx);
835 let name = path_param::<Repo>(cx);
836 let ctx = load_repo(cx, owner, name).await?;
837 let (user, _) = require_csrf(cx, &form.csrf).await?;
838 if !ctx.can_admin() {
839 return Err(forbidden().into());
840 }
841 let title = format!("{}/{}", ctx.repo.owner_name, ctx.repo.name);
842 let archived = match form.archived.as_str() {
843 "1" => true,
844 "0" => false,
845 _ => {
846 set_settings_notice(cx, "bad_form");
847 return Ok(back_to_settings(&title));
848 }
849 };
850 let state = app_context::<AppState>(cx);
851 state
852 .store
853 .set_repo_archived(&ctx.repo.id, archived)
854 .await?;
855 let action = if archived {
856 AuditAction::RepoArchived
857 } else {
858 AuditAction::RepoUnarchived
859 };
860 audit::record(
861 &state.store,
862 Some(cx),
863 Actor::User(&user),
864 action,
865 Target {
866 kind: "repo",
867 id: &ctx.repo.id,
868 name: &title,
869 },
870 serde_json::Value::Null,
871 )
872 .await;
873 tracing::info!(
874 actor_id = %user.id,
875 repo_id = %ctx.repo.id,
876 repo = %title,
877 archived,
878 "repo archive flag changed"
879 );
880 set_settings_notice(cx, if archived { "archived" } else { "unarchived" });
881 Ok(back_to_settings(&title))
882}
883
884/// Deletes the repo when the viewer may delete it, the CSRF token is valid and the confirmation
885/// is exactly `owner/name`. On success the repo is moved to trash, its rows are deleted, and the
886/// viewer is redirected home with a notice. If the database delete fails the directory goes back.
887/// A wrong confirmation re-renders the page with an error and changes nothing.
888#[page(POST "/{owner}/{repo}/settings/delete")]
889async fn settings_delete(cx: &Cx, Form(form): Form<DeleteForm>) -> Result<impl View> {
890 let owner = path_param::<Owner>(cx);
891 let name = path_param::<Repo>(cx);
892 let ctx = load_repo(cx, owner, name).await?;
893 let (user, session) = require_csrf(cx, &form.csrf).await?;
894 if !ctx.can_delete() {
895 return Err(forbidden().into());
896 }
897 let lang = i18n::lang(cx, Some(&user));
898 let title = format!("{}/{}", ctx.repo.owner_name, ctx.repo.name);
899 let error = if form.confirm.trim() == title {
900 None
901 } else {
902 Some(g::confirm_delete_mismatch(lang, &title))
903 };
904
905 if error.is_none() {
906 let state = app_context::<AppState>(cx);
907 let repo: &RepoRow = &ctx.repo;
908 let data_dir = &state.config.data_dir;
909 // A missing directory (already removed by hand) has nothing to trash, so only the rows go.
910 // Any other failure to inspect or move the directory aborts before the database is touched.
911 let trash_path = if repo_dir_missing(data_dir, &repo.id).map_err(forge_core::Error::from)? {
912 tracing::warn!(repo_id = %repo.id, "repo dir missing, deleting DB rows only");
913 None
914 } else {
915 Some(
916 move_to_trash(data_dir, &repo.id)
917 .await
918 .map_err(forge_core::Error::from)?,
919 )
920 };
921 // The wiki goes to the trash with the repo. A failure here puts the repo back first.
922 let wiki_trash = match move_wiki_to_trash(data_dir, &repo.id).await {
923 Ok(path) => path,
924 Err(e) => {
925 if let Some(trash_path) = &trash_path
926 && let Err(restore_err) =
927 restore_from_trash(data_dir, trash_path, &repo.id).await
928 {
929 tracing::error!(error = %restore_err, repo_id = %repo.id, "could not restore the repo after a failed wiki move");
930 }
931 return Err(forge_core::Error::from(e).into());
932 }
933 };
934 // Release assets: their rows go with the repo, so their blob ids are read first.
935 let release_assets = state.store.repo_release_asset_ids(&repo.id).await?;
936 if let Err(e) = state.store.delete_repo(&repo.id).await {
937 if let Some(trash_path) = &trash_path
938 && let Err(restore_err) = restore_from_trash(data_dir, trash_path, &repo.id).await
939 {
940 tracing::error!(
941 repo_id = %repo.id,
942 trash_path = %trash_path.display(),
943 error = %restore_err,
944 "failed to restore repo directory after a failed delete; move it back by hand"
945 );
946 }
947 if let Some(wiki_path) = &wiki_trash
948 && let Err(restore_err) =
949 restore_wiki_from_trash(data_dir, wiki_path, &repo.id).await
950 {
951 tracing::error!(
952 repo_id = %repo.id,
953 trash_path = %wiki_path.display(),
954 error = %restore_err,
955 "failed to restore wiki after a failed delete; move it back by hand"
956 );
957 }
958 return Err(e.into());
959 }
960 for asset_id in &release_assets {
961 if let Err(e) = state.blobs.delete(&crate::blobs::asset_key(asset_id)).await {
962 tracing::error!(error = %e, repo_id = %repo.id, "releases: could not delete an asset blob");
963 }
964 }
965 tracing::info!(
966 actor_id = %user.id,
967 actor = %user.username,
968 repo_id = %repo.id,
969 repo = %title,
970 trash_path = ?trash_path,
971 "repository deleted"
972 );
973 audit::record(
974 &state.store,
975 Some(cx),
976 Actor::User(&user),
977 AuditAction::RepoDeleted,
978 Target {
979 kind: "repo",
980 id: &repo.id,
981 name: &title,
982 },
983 serde_json::Value::Null,
984 )
985 .await;
986 set_notice(cx, &title);
987 return Err(see_other("/").into());
988 }
989
990 let rules = app_context::<AppState>(cx)
991 .store
992 .list_branch_rules(&ctx.repo.id)
993 .await?;
994 let page = SettingsView {
995 rules,
996 notice: None,
997 draft: RuleDraft::for_default_branch(&ctx.repo.default_branch),
998 delete_error: error,
999 };
1000 let csrf = session.csrf_token.clone();
1001 let frame = SettingsFrame::new(&ctx, app_context::<AppState>(cx), lang).await?;
1002 let branches = load_branch_names(&ctx).await?;
1003 Ok(view! {
1004 shell(title: frame.page_title.as_str(), viewer: ctx.viewer.as_ref(),
1005 settings_body(lang: lang, head: frame.head, default_branch: frame.default_branch, archived: frame.archived,
1006 branches: branches, title: frame.title, csrf: csrf,
1007 can_delete: frame.can_delete, page: page))
1008 })
1009}
1010
1011/// Adds a branch rule or updates the one with the same pattern. The form is checked for CSRF and
1012/// `Action::Admin` first. An invalid pattern re-renders the page with the error next to the form.
1013#[page(POST "/{owner}/{repo}/settings/branches")]
1014async fn settings_branch_save(cx: &Cx, Form(form): Form<BranchForm>) -> Result<impl View> {
1015 let owner = path_param::<Owner>(cx);
1016 let name = path_param::<Repo>(cx);
1017 let ctx = load_repo(cx, owner, name).await?;
1018 let (user, session) = require_csrf(cx, &form.csrf).await?;
1019 if !ctx.can_admin() {
1020 return Err(forbidden().into());
1021 }
1022 let lang = i18n::lang(cx, Some(&user));
1023 let pattern = form.pattern.trim().to_owned();
1024 let title = format!("{}/{}", ctx.repo.owner_name, ctx.repo.name);
1025
1026 let flags = match validate_pattern(&pattern)
1027 .map_err(|e| e.to_string())
1028 .and_then(|()| parse_rule_flags(lang, &form))
1029 {
1030 Ok(flags) => flags,
1031 Err(message) => {
1032 let rules = app_context::<AppState>(cx)
1033 .store
1034 .list_branch_rules(&ctx.repo.id)
1035 .await?;
1036 let page = SettingsView {
1037 rules,
1038 notice: None,
1039 draft: RuleDraft {
1040 pattern,
1041 allow_force_push: form.allow_force_push.is_some(),
1042 allow_deletion: form.allow_deletion.is_some(),
1043 required_approvals: form.required_approvals.clone(),
1044 block_on_changes_requested: form.block_on_changes_requested.is_some(),
1045 require_up_to_date: form.require_up_to_date.is_some(),
1046 restrict_push: form.restrict_push.is_some(),
1047 error: Some(message),
1048 },
1049 delete_error: None,
1050 };
1051 let csrf = session.csrf_token.clone();
1052 let frame = SettingsFrame::new(&ctx, app_context::<AppState>(cx), lang).await?;
1053 let branches = load_branch_names(&ctx).await?;
1054 return Ok(view! {
1055 shell(title: frame.page_title.as_str(), viewer: ctx.viewer.as_ref(),
1056 settings_body(lang: lang, head: frame.head, default_branch: frame.default_branch, archived: frame.archived,
1057 branches: branches, title: frame.title, csrf: csrf,
1058 can_delete: frame.can_delete, page: page))
1059 });
1060 }
1061 };
1062
1063 let state = app_context::<AppState>(cx);
1064 state
1065 .store
1066 .upsert_branch_rule(&ctx.repo.id, &pattern, flags)
1067 .await?;
1068 tracing::info!(
1069 actor_id = %user.id,
1070 repo_id = %ctx.repo.id,
1071 repo = %title,
1072 pattern = %pattern,
1073 allow_force_push = flags.allow_force_push,
1074 allow_deletion = flags.allow_deletion,
1075 required_approvals = flags.required_approvals,
1076 block_on_changes_requested = flags.block_on_changes_requested,
1077 require_up_to_date = flags.require_up_to_date,
1078 restrict_push = flags.restrict_push,
1079 "branch rule saved"
1080 );
1081 set_settings_notice(cx, "saved");
1082 Err(see_other(format!("/{title}/settings")).into())
1083}
1084
1085/// Removes a branch rule by id, scoped to this repo. Same checks as the save form.
1086#[route(POST "/{owner}/{repo}/settings/branches/delete")]
1087async fn settings_branch_delete(cx: &Cx, Form(form): Form<BranchDeleteForm>) -> Result<SeeOther> {
1088 let owner = path_param::<Owner>(cx);
1089 let name = path_param::<Repo>(cx);
1090 let ctx = load_repo(cx, owner, name).await?;
1091 let (user, _) = require_csrf(cx, &form.csrf).await?;
1092 if !ctx.can_admin() {
1093 return Err(forbidden().into());
1094 }
1095 let state = app_context::<AppState>(cx);
1096 state
1097 .store
1098 .delete_branch_rule(&ctx.repo.id, &form.rule_id)
1099 .await?;
1100 audit::record(
1101 &state.store,
1102 Some(cx),
1103 Actor::User(&user),
1104 AuditAction::BranchRuleDeleted,
1105 Target {
1106 kind: "branch_rule",
1107 id: &form.rule_id,
1108 name: "",
1109 },
1110 serde_json::Value::Null,
1111 )
1112 .await;
1113 set_settings_notice(cx, "removed");
1114 let title = format!("{}/{}", ctx.repo.owner_name, ctx.repo.name);
1115 Ok(see_other(format!("/{title}/settings")))
1116}
1117
1118#[cfg(test)]
1119mod tests {
1120 use forge_core::{Store, Visibility};
1121 use forge_git::repo::{init_bare, repo_path};
1122 use topcoat::router::request::Request;
1123 use topcoat::router::{Body, Router, to_bytes};
1124
1125 use crate::auth::SESSION_COOKIE;
1126 use crate::test_support::{app_router, create_session, test_state};
1127
1128 const CSRF: &str = "settings-test-csrf";
1129
1130 /// Posts the branch form as `alice` and returns the status and body.
1131 async fn post_rule(app: &Router, cookie: &str, fields: &[(&str, &str)]) -> (u16, String) {
1132 let mut form = form_urlencoded::Serializer::new(String::new());
1133 form.append_pair("csrf", CSRF);
1134 for (name, value) in fields {
1135 form.append_pair(name, value);
1136 }
1137 let req = Request::builder()
1138 .method("POST")
1139 .uri("/alice/proj/settings/branches")
1140 .header("content-type", "application/x-www-form-urlencoded")
1141 .header("cookie", cookie)
1142 .body(Body::from(form.finish()))
1143 .expect("request");
1144 let res = app.handle(req).await;
1145 let status = res.status().as_u16();
1146 let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body");
1147 (status, String::from_utf8_lossy(&bytes).into_owned())
1148 }
1149
1150 #[tokio::test]
1151 async fn branch_rule_form_saves_merge_and_push_fields() {
1152 let tmp = tempfile::tempdir().expect("tempdir");
1153 let state = test_state(tmp.path()).await;
1154 let store: Store = state.store.clone();
1155 let alice = store
1156 .create_user("alice", None, "unused", false)
1157 .await
1158 .expect("alice");
1159 let repo = store
1160 .create_repo(&alice.id, "proj", "", Visibility::Private, "main")
1161 .await
1162 .expect("repo");
1163 let bare = repo_path(&state.git.data_dir, &repo.id).expect("repo path");
1164 init_bare(&state.git, &bare, "main")
1165 .await
1166 .expect("init bare");
1167 create_session(&store, &alice.id, "alice-token", CSRF).await;
1168 let cookie = format!("__Host-{SESSION_COOKIE}=alice-token");
1169 let app = app_router(state.clone());
1170
1171 let (status, body) = post_rule(
1172 &app,
1173 &cookie,
1174 &[
1175 ("pattern", "main"),
1176 ("required_approvals", " 3 "),
1177 ("block_on_changes_requested", "on"),
1178 ("require_up_to_date", "on"),
1179 ("restrict_push", "on"),
1180 ],
1181 )
1182 .await;
1183 assert_eq!(status, 303, "body: {body}");
1184 let rules = store.list_branch_rules(&repo.id).await.expect("rules");
1185 assert_eq!(rules.len(), 1);
1186 let rule = &rules[0];
1187 assert_eq!(rule.pattern, "main");
1188 assert_eq!(rule.required_approvals, 3);
1189 assert!(rule.block_on_changes_requested);
1190 assert!(rule.require_up_to_date);
1191 assert!(rule.restrict_push);
1192 assert!(!rule.allow_force_push && !rule.allow_deletion);
1193
1194 // A blank count means 0 and unchecked boxes are off.
1195 let (status, _) = post_rule(
1196 &app,
1197 &cookie,
1198 &[("pattern", "main"), ("required_approvals", "")],
1199 )
1200 .await;
1201 assert_eq!(status, 303);
1202 let rule = store.list_branch_rules(&repo.id).await.expect("rules")[0].clone();
1203 assert_eq!(rule.required_approvals, 0);
1204 assert!(
1205 !rule.block_on_changes_requested && !rule.require_up_to_date && !rule.restrict_push
1206 );
1207
1208 // Out of range re-renders the form with the error and saves nothing.
1209 let (status, body) = post_rule(
1210 &app,
1211 &cookie,
1212 &[
1213 ("pattern", "main"),
1214 ("required_approvals", "21"),
1215 ("restrict_push", "on"),
1216 ],
1217 )
1218 .await;
1219 assert_eq!(status, 200);
1220 assert!(
1221 body.contains("required approvals must be a whole number from 0 to 20"),
1222 "{body}"
1223 );
1224 let rule = store.list_branch_rules(&repo.id).await.expect("rules")[0].clone();
1225 assert_eq!(rule.required_approvals, 0);
1226 assert!(!rule.restrict_push);
1227
1228 // Not a number is refused the same way.
1229 let (status, body) = post_rule(
1230 &app,
1231 &cookie,
1232 &[("pattern", "main"), ("required_approvals", "two")],
1233 )
1234 .await;
1235 assert_eq!(status, 200);
1236 assert!(
1237 body.contains("required approvals must be a whole number from 0 to 20"),
1238 "{body}"
1239 );
1240 }
1241}