Repository settings: one page per setting under a grouped sidebar (#278) c7067c7bda56
Settings was one long page of anchored sections. Each setting is now a
page at /{owner}/{name}/settings/{page} (general, collaborators, landing,
checks, push-protection, flow, runs, gates, runners, variables, secrets,
coverage, webhooks, assistants, git-mirror, diffusion, storage, backup,
advanced) beside a sidebar grouped General, Code, Flow, Integrations,
Data and Danger zone, the current page marked with aria-current;
/settings shows General. Pages a role cannot use are left out of the
sidebar and not found; the admins' pages stay theirs. An archived
repository's settings open for its admins, who bring it back there.
Every form keeps its POST route and now redirects to the page holding
it with its notice or error (a runner token is still shown once in
place). Links elsewhere point at the pages, the push protection refusal
names /settings/push-protection, and a kept /settings#section link is
sent on by hub.js from the General page. On narrow screens the sidebar
is a compact list above the page. Tests cover every page by role, the
redirects and the marked page; the spec's pages table describes it.
Closes #278
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key c505b80c7aa7
- When
- · 2026-10-20 15:11 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
c7067c7bda562389bdf8e8e005ecce14a40ce4667862d998041d41551edec507- Depends on
- 0b20f1ec4007 0ee5c3fb32eb 107eaba52c9d 119fd17d213f 145306044896 1553bfc9ae53 155cab142b63 1870e4fa9808 18e381ea88c2 19bb31ff1f5c 1f78655a0466 206c1fc1b06a 2a83f5303458 3f4b0ea6eabf 42fa95540468 4a68f1064b08 4ac01a732552 4f6925337848 5e6d6484c28e 64caed18236a 66fd9d6b1452 7974adb0be66 7b26e6118bc0 7edaff01d506 8fb35df7d66e 9008bf6c2b8c 93be9ce4653e 961c10bee174 9756c67d3aec a160b98fb922 a7aa76ac52d7 a7e6e8e6094c a9f1c0bf403a b5668108487f b64e2537d702 cb7b3cd45dc0 d0fbea2eb46e d23464c0ac60 d3b048b27277 de505df5cf8d e71583501b1d e71846aa07b9 ea55fb62f919 f4d6ef418a96
- Depended on by
- 5588bb3631d2 737de0fac722 367f34b8e590 d366306dae7d a0a1601eb5a5
- In bookmarks
- api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
- In tags
- v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 273 atoms, 0 tree operations, 24 files
Browse the tree at this change
modified CHANGELOG.md +1 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Repository settings are one page per setting (#278):** a repository's Settings was one long page; it is now a sidebar of grouped pages (General, Code, Flow, Integrations, Data, Danger zone) with the page shown beside it, the current one marked, each at its own address (`/{owner}/{name}/settings/push-protection`, `/settings/runs`, …; `/settings` is General). Pages a role cannot use are not listed and are not found. Every form keeps its address and now returns to its own page with what happened, so reloading does not send it again. Links elsewhere point at the new pages (the push protection refusal names `/settings/push-protection`), and a kept `/settings#section` link opens the page that has it. On a narrow screen the sidebar becomes a compact list above the page. An archived repository's settings open for its admins again, so they can bring it back. |
|
| 7 | 8 | - **Proposal lists and the proposals API are much faster on large files:** listing a repository's proposals rendered every open proposal's full diff and looked up each new line's history separately, recomputing a whole file's lines for every line; lists, the API, auto-land and the checks after a push now skip the rendering, and the proposal page computes each file's lines once. |
|
| 8 | 9 | - **Keyless agents can push release tags (andymccall/oxygen#129):** A tag signed by a key the hub certified (an agent's or a Flow job's ten-minute key) was refused "unknown key", and then refused by the policy. With oxy's fix the hub accepts it under the policy's subject grant and countersigns when it accepted it, as it does a bookmark move. It logs the tag's acceptance in the transparency log beside the tag, and serves both with the tag in every refs answer, so a client fetching the tag months later checks the certificate at the hub's time. Runners read repositories with their own oxy, so they need rebuilding to check such tags; older ones ignore the new fields. |
|
| 9 | 10 | - **Secret scanning finds what is already in history (#277):** push protection's rules now also run over each repository's existing history in the background (every change reachable from its bookmarks: the lines it inserts and the text files it replaces), so a credential committed before push protection existed, or while it was off, is found too. Every repository's whole history is scanned once after this deploy, then only what each push adds, a budget at a time (`[limits] history_scan_limit`, 64 MiB, and `history_scan_time_ms`, 5 s, per repository per pass; `0` turns it off), at low priority, resuming where it stopped. Maintainers see the findings, with the change, bookmark, file and line, under Settings › Push protection › Found in history, and either allow one (not a secret) or mark it resolved with how it was revoked (audited as `secret.resolved`); "Scan history now" scans the whole history again. An allowed fingerprint can now be removed (audited as `secret.bypass_removed`), so pushes adding that value are refused again. Repositories with push protection off are not scanned. |
|
| … | 256 unchanged lines |
modified assets/hub.js +13 −0
| … | 915 unchanged lines |
||
| 916 | 916 | sel.addEventListener("change", show); |
|
| 917 | 917 | show(); |
|
| 918 | 918 | })(); |
|
| 919 | + | ||
| 920 | + | /* A repository's settings were one page with a section per #anchor; |
|
| 921 | + | each is a page of its own now (oxygenhub#278). A kept link such as |
|
| 922 | + | /settings#flow-priority opens the page that holds that section, when |
|
| 923 | + | the viewer has it (its link is in the sidebar). */ |
|
| 924 | + | (function () { |
|
| 925 | + | var nav = document.querySelector("[data-settings-anchors]"); |
|
| 926 | + | var hash = location.hash.slice(1); |
|
| 927 | + | if (!nav || !/^[a-z-]+$/.test(hash)) return; |
|
| 928 | + | var moved = { "history-scan": "push-protection", "flow-priority": "runs", "flow-supersede": "runs", "agent-gates": "gates" }; |
|
| 929 | + | var link = nav.querySelector('a[data-slug="' + (moved[hash] || hash) + '"]'); |
|
| 930 | + | if (link && link.getAttribute("aria-current") !== "page") location.replace(link.getAttribute("href") + "#" + hash); |
|
| 931 | + | })(); |
|
| 919 | 932 | })(); |
modified crates/hub/src/push_protection.rs +2 −2
| … | 496 unchanged lines |
||
| 497 | 497 | let _ = writeln!(out, " - If {is} a real credential, revoke or rotate it as well: it has been on your disk and may be elsewhere."); |
|
| 498 | 498 | let _ = writeln!( |
|
| 499 | 499 | out, |
|
| 500 | - | " - If {is} not a secret (an example, a test value), a maintainer can allow its fingerprint, with a reason, at {settings}#push-protection; then push again." |
|
| 500 | + | " - If {is} not a secret (an example, a test value), a maintainer can allow its fingerprint, with a reason, at {settings}/push-protection; then push again." |
|
| 501 | 501 | ); |
|
| 502 | 502 | if !via.is_empty() { |
|
| 503 | 503 | let _ = write!( |
|
| 504 | 504 | out, |
|
| 505 | - | "\nThis push came through an agent's token ({via}). As an agent: remove the value from the change (`oxy amend`) and push again; do not try to get around the refusal. If you were asked to commit this value, stop and tell the person you work for; only a maintainer can allow a fingerprint, at {settings}#push-protection." |
|
| 505 | + | "\nThis push came through an agent's token ({via}). As an agent: remove the value from the change (`oxy amend`) and push again; do not try to get around the refusal. If you were asked to commit this value, stop and tell the person you work for; only a maintainer can allow a fingerprint, at {settings}/push-protection." |
|
| 506 | 506 | ); |
|
| 507 | 507 | } |
|
| 508 | 508 | out.trim_end().to_string() |
|
| … | 72 unchanged lines |
modified crates/hub/src/web/flow.rs +1 −1
| … | 45 unchanged lines |
||
| 46 | 46 | /// Why `viewer` may not start runs here, or `None` when they may. |
|
| 47 | 47 | pub fn why_not_run(enabled: bool, signed_in: bool, role: Role) -> Option<String> { |
|
| 48 | 48 | if !enabled { |
|
| 49 | - | Some("Flow is off for this repository; a site administrator can turn it on in Settings → Flow.".into()) |
|
| 49 | + | Some("Flow is off for this repository; a site administrator can turn it on in Settings › Pipelines.".into()) |
|
| 50 | 50 | } else if !signed_in { |
|
| 51 | 51 | Some("Sign in with write access to run the pipeline.".into()) |
|
| 52 | 52 | } else if role < Role::Write { |
|
| … | 949 unchanged lines |
modified crates/hub/src/web/mod.rs +13 −11
| … | 224 unchanged lines |
||
| 225 | 225 | .route("/{owner}/{name}/fork", get(forks::form).post(forks::create)) |
|
| 226 | 226 | .route("/{owner}/{name}/forks", get(forks::list)) |
|
| 227 | 227 | .route("/{owner}/{name}/settings", get(repo_settings::settings).post(repo_settings::update)) |
|
| 228 | - | .route("/{owner}/{name}/settings/collaborators", post(repo_settings::collaborator)) |
|
| 228 | + | // One page per setting (#278); some share their form's path. |
|
| 229 | + | .route("/{owner}/{name}/settings/{page}", get(repo_settings::page)) |
|
| 230 | + | .route("/{owner}/{name}/settings/collaborators", get(repo_settings::page).post(repo_settings::collaborator)) |
|
| 229 | 231 | .route("/{owner}/{name}/settings/delete", post(repo_settings::delete)) |
|
| 230 | 232 | .route("/{owner}/{name}/settings/transfer", post(repo_settings::transfer)) |
|
| 231 | 233 | .route("/{owner}/{name}/settings/archive", post(repo_settings::archive)) |
|
| … | 28 unchanged lines |
||
| 260 | 262 | .route("/{owner}/{name}/issue/{number}/project", post(projects::place_issue)) |
|
| 261 | 263 | .route("/{owner}/{name}/proposal/{number}/events", get(proposals::events)) |
|
| 262 | 264 | .route("/{owner}/{name}/settings/hooks", post(repo_settings::hooks)) |
|
| 263 | - | .route("/{owner}/{name}/settings/assistants", post(repo_settings::assistants)) |
|
| 265 | + | .route("/{owner}/{name}/settings/assistants", get(repo_settings::page).post(repo_settings::assistants)) |
|
| 264 | 266 | .route("/{owner}/{name}/settings/assistants/models", post(repo_settings::assistant_models)) |
|
| 265 | - | .route("/{owner}/{name}/settings/variables", post(repo_settings::variables)) |
|
| 266 | - | .route("/{owner}/{name}/settings/secrets", post(repo_settings::secrets)) |
|
| 267 | + | .route("/{owner}/{name}/settings/variables", get(repo_settings::page).post(repo_settings::variables)) |
|
| 268 | + | .route("/{owner}/{name}/settings/secrets", get(repo_settings::page).post(repo_settings::secrets)) |
|
| 267 | 269 | .route("/{owner}/{name}/settings/hooks/{id}/deliveries", get(repo_settings::deliveries)) |
|
| 268 | 270 | .route("/{owner}/{name}/settings/hooks/{id}/redeliver/{delivery}", post(repo_settings::redeliver)) |
|
| 269 | - | .route("/{owner}/{name}/settings/checks", post(repo_settings::required_checks)) |
|
| 271 | + | .route("/{owner}/{name}/settings/checks", get(repo_settings::page).post(repo_settings::required_checks)) |
|
| 270 | 272 | .route("/{owner}/{name}/watch", post(issues::watch)) |
|
| 271 | 273 | .route("/{owner}/{name}/star", post(issues::star)) |
|
| 272 | 274 | .route("/{owner}/{name}/edit/{r}", post(edit::save)) |
|
| … | 44 unchanged lines |
||
| 317 | 319 | .route("/{owner}/{name}/git-upload-pack", post(git_upload_pack)) |
|
| 318 | 320 | .route("/{owner}/{name}/git-receive-pack", post(git_receive_pack)) |
|
| 319 | 321 | .route("/{owner}/{name}/settings/mirror", post(repo_settings::mirror)) |
|
| 320 | - | .route("/{owner}/{name}/settings/flow", post(repo_settings::flow)) |
|
| 322 | + | .route("/{owner}/{name}/settings/flow", get(repo_settings::page).post(repo_settings::flow)) |
|
| 321 | 323 | .route("/{owner}/{name}/settings/flow-priority", post(repo_settings::flow_priority)) |
|
| 322 | 324 | .route("/{owner}/{name}/settings/agent-gates", post(repo_settings::agent_gates)) |
|
| 323 | - | .route("/{owner}/{name}/settings/push-protection", post(repo_settings::push_protection)) |
|
| 325 | + | .route("/{owner}/{name}/settings/push-protection", get(repo_settings::page).post(repo_settings::push_protection)) |
|
| 324 | 326 | .route("/{owner}/{name}/settings/secret-bypass", post(repo_settings::secret_bypass)) |
|
| 325 | 327 | .route("/{owner}/{name}/settings/secret-bypass/remove", post(repo_settings::secret_bypass_remove)) |
|
| 326 | 328 | .route("/{owner}/{name}/settings/secret-resolve", post(repo_settings::secret_resolve)) |
|
| 327 | 329 | .route("/{owner}/{name}/settings/secret-scan", post(repo_settings::secret_scan)) |
|
| 328 | 330 | .route("/{owner}/{name}/settings/flow-supersede", post(repo_settings::flow_supersede)) |
|
| 329 | - | .route("/{owner}/{name}/settings/diffusion", post(repo_settings::diffusion)) |
|
| 330 | - | .route("/{owner}/{name}/settings/backup", post(repo_settings::backup)) |
|
| 331 | - | .route("/{owner}/{name}/settings/storage", post(repo_settings::storage)) |
|
| 332 | - | .route("/{owner}/{name}/settings/coverage", post(repo_settings::coverage)) |
|
| 331 | + | .route("/{owner}/{name}/settings/diffusion", get(repo_settings::page).post(repo_settings::diffusion)) |
|
| 332 | + | .route("/{owner}/{name}/settings/backup", get(repo_settings::page).post(repo_settings::backup)) |
|
| 333 | + | .route("/{owner}/{name}/settings/storage", get(repo_settings::page).post(repo_settings::storage)) |
|
| 334 | + | .route("/{owner}/{name}/settings/coverage", get(repo_settings::page).post(repo_settings::coverage)) |
|
| 333 | 335 | .route("/{owner}/{name}/settings/runners/token", post(repo_settings::runner_token)) |
|
| 334 | 336 | .route("/{owner}/{name}/settings/runners/{id}/remove", post(repo_settings::runner_remove)) |
|
| 335 | 337 | .route("/{owner}/{name}/issues", get(issues::list)) |
|
| … | 535 unchanged lines |
modified crates/hub/src/web/repo_settings.rs +329 −142
| … | 6 unchanged lines |
||
| 7 | 7 | use crate::repos::RepoName; |
|
| 8 | 8 | use crate::Shared; |
|
| 9 | 9 | use askama::Template; |
|
| 10 | - | use axum::extract::{Form, Path, State}; |
|
| 10 | + | use axum::extract::{Form, Path, Query, State}; |
|
| 11 | + | use axum::http::Uri; |
|
| 11 | 12 | use axum::response::{IntoResponse, Redirect, Response}; |
|
| 12 | 13 | use hub_api::Visibility; |
|
| 13 | 14 | use serde::Deserialize; |
|
| … | 143 unchanged lines |
||
| 157 | 158 | Ok(Redirect::to(&name.url()).into_response()) |
|
| 158 | 159 | } |
|
| 159 | 160 | ||
| 161 | + | /// One page of a repository's settings (#278), at |
|
| 162 | + | /// `/{owner}/{name}/settings/{slug}`; `/settings` is General. |
|
| 163 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
|
| 164 | + | pub enum Section { |
|
| 165 | + | General, |
|
| 166 | + | Collaborators, |
|
| 167 | + | Landing, |
|
| 168 | + | Checks, |
|
| 169 | + | PushProtection, |
|
| 170 | + | Pipelines, |
|
| 171 | + | Runs, |
|
| 172 | + | Gates, |
|
| 173 | + | Runners, |
|
| 174 | + | Variables, |
|
| 175 | + | Secrets, |
|
| 176 | + | Coverage, |
|
| 177 | + | Webhooks, |
|
| 178 | + | Assistants, |
|
| 179 | + | GitMirror, |
|
| 180 | + | Diffusion, |
|
| 181 | + | Storage, |
|
| 182 | + | Backup, |
|
| 183 | + | Advanced, |
|
| 184 | + | } |
|
| 185 | + | ||
| 186 | + | /// The sidebar: each group's heading and its pages, in order. |
|
| 187 | + | const GROUPS: &[(&str, &[Section])] = &[ |
|
| 188 | + | ("General", &[Section::General, Section::Collaborators]), |
|
| 189 | + | ("Code", &[Section::Landing, Section::Checks, Section::PushProtection]), |
|
| 190 | + | ( |
|
| 191 | + | "Flow", |
|
| 192 | + | &[Section::Pipelines, Section::Runs, Section::Gates, Section::Runners, Section::Variables, Section::Secrets, Section::Coverage], |
|
| 193 | + | ), |
|
| 194 | + | ("Integrations", &[Section::Webhooks, Section::Assistants, Section::GitMirror, Section::Diffusion]), |
|
| 195 | + | ("Data", &[Section::Storage, Section::Backup]), |
|
| 196 | + | ("Danger zone", &[Section::Advanced]), |
|
| 197 | + | ]; |
|
| 198 | + | ||
| 199 | + | impl Section { |
|
| 200 | + | pub fn slug(self) -> &'static str { |
|
| 201 | + | match self { |
|
| 202 | + | Section::General => "general", |
|
| 203 | + | Section::Collaborators => "collaborators", |
|
| 204 | + | Section::Landing => "landing", |
|
| 205 | + | Section::Checks => "checks", |
|
| 206 | + | Section::PushProtection => "push-protection", |
|
| 207 | + | Section::Pipelines => "flow", |
|
| 208 | + | Section::Runs => "runs", |
|
| 209 | + | Section::Gates => "gates", |
|
| 210 | + | Section::Runners => "runners", |
|
| 211 | + | Section::Variables => "variables", |
|
| 212 | + | Section::Secrets => "secrets", |
|
| 213 | + | Section::Coverage => "coverage", |
|
| 214 | + | Section::Webhooks => "webhooks", |
|
| 215 | + | Section::Assistants => "assistants", |
|
| 216 | + | Section::GitMirror => "git-mirror", |
|
| 217 | + | Section::Diffusion => "diffusion", |
|
| 218 | + | Section::Storage => "storage", |
|
| 219 | + | Section::Backup => "backup", |
|
| 220 | + | Section::Advanced => "advanced", |
|
| 221 | + | } |
|
| 222 | + | } |
|
| 223 | + | ||
| 224 | + | pub fn title(self) -> &'static str { |
|
| 225 | + | match self { |
|
| 226 | + | Section::General => "General", |
|
| 227 | + | Section::Collaborators => "Collaborators", |
|
| 228 | + | Section::Landing => "Landing rules", |
|
| 229 | + | Section::Checks => "Required checks", |
|
| 230 | + | Section::PushProtection => "Push protection", |
|
| 231 | + | Section::Pipelines => "Pipelines", |
|
| 232 | + | Section::Runs => "Runs & priority", |
|
| 233 | + | Section::Gates => "Gates & agents", |
|
| 234 | + | Section::Runners => "Runners", |
|
| 235 | + | Section::Variables => "Variables", |
|
| 236 | + | Section::Secrets => "Secrets", |
|
| 237 | + | Section::Coverage => "Coverage", |
|
| 238 | + | Section::Webhooks => "Webhooks", |
|
| 239 | + | Section::Assistants => "AI assistants", |
|
| 240 | + | Section::GitMirror => "Git mirror", |
|
| 241 | + | Section::Diffusion => "Diffusion", |
|
| 242 | + | Section::Storage => "Storage", |
|
| 243 | + | Section::Backup => "Backup", |
|
| 244 | + | Section::Advanced => "Advanced", |
|
| 245 | + | } |
|
| 246 | + | } |
|
| 247 | + | ||
| 248 | + | pub fn parse(slug: &str) -> Option<Section> { |
|
| 249 | + | GROUPS.iter().flat_map(|(_, pages)| pages.iter().copied()).find(|s| s.slug() == slug) |
|
| 250 | + | } |
|
| 251 | + | ||
| 252 | + | /// Whether only the repository's admins use it; its maintainers use |
|
| 253 | + | /// the rest. The roles are those of each page's forms. |
|
| 254 | + | fn admin_only(self) -> bool { |
|
| 255 | + | matches!( |
|
| 256 | + | self, |
|
| 257 | + | Section::Collaborators |
|
| 258 | + | | Section::Runners |
|
| 259 | + | | Section::Variables |
|
| 260 | + | | Section::Secrets |
|
| 261 | + | | Section::Webhooks |
|
| 262 | + | | Section::Assistants |
|
| 263 | + | | Section::Storage |
|
| 264 | + | | Section::Advanced |
|
| 265 | + | ) |
|
| 266 | + | } |
|
| 267 | + | ||
| 268 | + | /// Whether this viewer has the page: Storage only for a repository kept |
|
| 269 | + | /// in a bucket. |
|
| 270 | + | fn shown(self, role: Role, in_bucket: bool) -> bool { |
|
| 271 | + | (!self.admin_only() || role == Role::Admin) && (self != Section::Storage || in_bucket) |
|
| 272 | + | } |
|
| 273 | + | } |
|
| 274 | + | ||
| 275 | + | /// The sidebar a viewer sees: the groups with at least one page for them. |
|
| 276 | + | fn sidebar(role: Role, in_bucket: bool) -> Vec<(&'static str, Vec<Section>)> { |
|
| 277 | + | GROUPS |
|
| 278 | + | .iter() |
|
| 279 | + | .map(|(group, pages)| (*group, pages.iter().copied().filter(|s| s.shown(role, in_bucket)).collect::<Vec<_>>())) |
|
| 280 | + | .filter(|(_, pages)| !pages.is_empty()) |
|
| 281 | + | .collect() |
|
| 282 | + | } |
|
| 283 | + | ||
| 284 | + | /// Back to the page that holds a settings form, with what happened (#278). |
|
| 285 | + | fn back(row: &db::repos::RepoRow, section: Section, notice: Option<String>, error: Option<String>) -> Result<Response, Error> { |
|
| 286 | + | let mut to = format!("/{}/{}/settings/{}", row.owner, row.name, section.slug()); |
|
| 287 | + | if let Some(n) = notice { |
|
| 288 | + | to.push_str(&format!("?notice={}", super::settings::urlencode(&n))); |
|
| 289 | + | } else if let Some(e) = error { |
|
| 290 | + | to.push_str(&format!("?error={}", super::settings::urlencode(&e))); |
|
| 291 | + | } |
|
| 292 | + | Ok(Redirect::to(&to).into_response()) |
|
| 293 | + | } |
|
| 294 | + | ||
| 160 | 295 | #[derive(Template)] |
|
| 161 | 296 | #[template(path = "repo_settings.html")] |
|
| 162 | 297 | pub struct RepoSettingsPage { |
|
| 298 | + | /// The page shown, and the sidebar's groups of pages. |
|
| 299 | + | section: Section, |
|
| 300 | + | nav: Vec<(&'static str, Vec<Section>)>, |
|
| 163 | 301 | shell: Shell, |
|
| 164 | 302 | repo: RepoShell, |
|
| 165 | 303 | row: db::repos::RepoRow, |
|
| … | 48 unchanged lines |
||
| 214 | 352 | } |
|
| 215 | 353 | } |
|
| 216 | 354 | ||
| 355 | + | /// One settings page, or not found when the viewer's role does not have it. |
|
| 217 | 356 | async fn settings_page( |
|
| 218 | 357 | app: &Shared, |
|
| 219 | 358 | shell: Shell, |
|
| 220 | 359 | row: db::repos::RepoRow, |
|
| 221 | 360 | role: Role, |
|
| 361 | + | section: Section, |
|
| 222 | 362 | notice: Option<String>, |
|
| 223 | 363 | error: Option<String>, |
|
| 224 | 364 | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 365 | + | let storage = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 366 | + | if !section.shown(role, storage.is_some()) { |
|
| 367 | + | return Err(Error::not_found(shell)); |
|
| 368 | + | } |
|
| 369 | + | let nav = sidebar(role, storage.is_some()); |
|
| 225 | 370 | let collaborators = accounts::collaborators(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 226 | 371 | let teams = match accounts::org_by_name(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 227 | 372 | Some(org) => accounts::teams(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?, |
|
| … | 28 unchanged lines |
||
| 256 | 401 | let assistants = super::assistants::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 257 | 402 | let variables = super::variables::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 258 | 403 | let secrets = super::secrets::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 259 | - | let storage = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 260 | 404 | let coverage = crate::coverage::rules(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 261 | 405 | let backup_owner = crate::backups::setting(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 262 | 406 | let push_findings = db::push_protection::recent(&app.db, row.id, 50).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| … | 3 unchanged lines |
||
| 266 | 410 | let resolved_findings = db::push_protection::resolved(&app.db, row.id, 50).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 267 | 411 | let history_scan_on = crate::history_scan::Budget::of(&app.config).is_some(); |
|
| 268 | 412 | Ok(Page(RepoSettingsPage { |
|
| 413 | + | section, |
|
| 414 | + | nav, |
|
| 269 | 415 | push_findings, |
|
| 270 | 416 | secret_bypasses, |
|
| 271 | 417 | history_scan, |
|
| … | 25 unchanged lines |
||
| 297 | 443 | notice, |
|
| 298 | 444 | error, |
|
| 299 | 445 | })) |
|
| 446 | + | } |
|
| 447 | + | ||
| 448 | + | /// What a settings form did, carried to the page it redirects to. |
|
| 449 | + | #[derive(Deserialize, Default)] |
|
| 450 | + | pub struct Outcome { |
|
| 451 | + | #[serde(default)] |
|
| 452 | + | notice: Option<String>, |
|
| 453 | + | #[serde(default)] |
|
| 454 | + | error: Option<String>, |
|
| 300 | 455 | } |
|
| 301 | 456 | ||
| 457 | + | /// `GET /{owner}/{name}/settings`: the General page. |
|
| 302 | 458 | pub async fn settings( |
|
| 303 | 459 | State(app): State<Shared>, |
|
| 304 | 460 | viewer: Viewer, |
|
| 305 | 461 | Path((owner, name)): Path<(String, String)>, |
|
| 462 | + | Query(q): Query<Outcome>, |
|
| 463 | + | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 464 | + | let path = format!("/{owner}/{name}/settings"); |
|
| 465 | + | show(&app, &viewer, &path, &owner, &name, Section::General, q).await |
|
| 466 | + | } |
|
| 467 | + | ||
| 468 | + | /// `GET /{owner}/{name}/settings/{page}` (#278): one page of the settings. |
|
| 469 | + | /// Some pages share their path with their form's `POST`, so the page is |
|
| 470 | + | /// the path's last segment. |
|
| 471 | + | pub async fn page( |
|
| 472 | + | State(app): State<Shared>, |
|
| 473 | + | viewer: Viewer, |
|
| 474 | + | Path(params): Path<std::collections::HashMap<String, String>>, |
|
| 475 | + | uri: Uri, |
|
| 476 | + | Query(q): Query<Outcome>, |
|
| 477 | + | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 478 | + | let (owner, name) = (params.get("owner").cloned().unwrap_or_default(), params.get("name").cloned().unwrap_or_default()); |
|
| 479 | + | let path = format!("/{owner}/{name}/settings"); |
|
| 480 | + | let slug = uri.path().rsplit('/').next().unwrap_or(""); |
|
| 481 | + | let Some(section) = Section::parse(slug) else { |
|
| 482 | + | return Err(Error::not_found(Shell::new(&app, &path, &viewer))); |
|
| 483 | + | }; |
|
| 484 | + | show(&app, &viewer, &format!("{path}/{slug}"), &owner, &name, section, q).await |
|
| 485 | + | } |
|
| 486 | + | ||
| 487 | + | /// A settings page for the repository's maintainers, or its admins: on an |
|
| 488 | + | /// archived repository, only its admins, who can bring it back. |
|
| 489 | + | async fn show( |
|
| 490 | + | app: &Shared, |
|
| 491 | + | viewer: &Viewer, |
|
| 492 | + | path: &str, |
|
| 493 | + | owner: &str, |
|
| 494 | + | name: &str, |
|
| 495 | + | section: Section, |
|
| 496 | + | q: Outcome, |
|
| 306 | 497 | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 307 | - | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 308 | - | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 309 | - | settings_page(&app, shell, row, role, None, None).await |
|
| 498 | + | let shell = Shell::new(app, path, viewer); |
|
| 499 | + | let (_, row, role) = lookup_role(app, &shell, viewer, owner, name, Access::Read).await?; |
|
| 500 | + | if role < Role::Maintain { |
|
| 501 | + | return Err(Error::not_found(shell)); |
|
| 502 | + | } |
|
| 503 | + | if row.archived && role < Role::Admin { |
|
| 504 | + | return Err(super::archived(&shell)); |
|
| 505 | + | } |
|
| 506 | + | settings_page(app, shell, row, role, section, q.notice, q.error).await |
|
| 310 | 507 | } |
|
| 311 | 508 | ||
| 312 | 509 | #[derive(Deserialize)] |
|
| … | 12 unchanged lines |
||
| 325 | 522 | viewer: Viewer, |
|
| 326 | 523 | Path((owner, name)): Path<(String, String)>, |
|
| 327 | 524 | Form(f): Form<GeneralForm>, |
|
| 328 | - | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 525 | + | ) -> Result<Response, Error> { |
|
| 329 | 526 | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 330 | 527 | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 331 | 528 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 332 | 529 | let e = |e| Error::from_db(shell.clone(), e); |
|
| 333 | 530 | let topics = match db::repos::parse_topics(&f.topics) { |
|
| 334 | 531 | Ok(t) => t, |
|
| 335 | - | Err(msg) => return settings_page(&app, shell, row, role, None, Some(msg)).await, |
|
| 532 | + | Err(msg) => return back(&row, Section::General, None, Some(msg)), |
|
| 336 | 533 | }; |
|
| 337 | 534 | db::repos::set_description(&app.db, row.id, f.description.trim()).await.map_err(e)?; |
|
| 338 | 535 | db::repos::set_topics(&app.db, row.id, &topics).await.map_err(e)?; |
|
| … | 7 unchanged lines |
||
| 346 | 543 | } |
|
| 347 | 544 | accounts::audit(&app.db, viewer.account(), "repo.updated", &format!("{owner}/{name}"), "").await.ok(); |
|
| 348 | 545 | let row = db::repos::get_by_id(&app.db, row.id).await.map_err(e)?.unwrap_or(row); |
|
| 349 | - | settings_page(&app, shell, row, role, Some("Saved.".into()), None).await |
|
| 546 | + | back(&row, Section::General, Some("Saved.".into()), None) |
|
| 350 | 547 | } |
|
| 351 | 548 | ||
| 352 | 549 | #[derive(Deserialize)] |
|
| … | 15 unchanged lines |
||
| 368 | 565 | viewer: Viewer, |
|
| 369 | 566 | Path((owner, name)): Path<(String, String)>, |
|
| 370 | 567 | Form(f): Form<CollaboratorForm>, |
|
| 371 | - | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 568 | + | ) -> Result<Response, Error> { |
|
| 372 | 569 | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 373 | - | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 570 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 374 | 571 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 375 | 572 | let e = |e| Error::from_db(shell.clone(), e); |
|
| 376 | 573 | let (account_id, team_id, label) = if !f.username.trim().is_empty() { |
|
| 377 | 574 | match accounts::account_by_name(&app.db, f.username.trim()).await.map_err(e)? { |
|
| 378 | 575 | Some(a) => (Some(a.id), None, a.username), |
|
| 379 | - | None => return settings_page(&app, shell, row, role, None, Some("No such account.".into())).await, |
|
| 576 | + | None => return back(&row, Section::Collaborators, None, Some("No such account.".into())), |
|
| 380 | 577 | } |
|
| 381 | 578 | } else if !f.team.trim().is_empty() { |
|
| 382 | 579 | let org = accounts::org_by_name(&app.db, &row.owner).await.map_err(e)?; |
|
| … | 3 unchanged lines |
||
| 386 | 583 | }; |
|
| 387 | 584 | match team { |
|
| 388 | 585 | Some(t) => (None, Some(t.id), format!("team {}", t.name)), |
|
| 389 | - | None => return settings_page(&app, shell, row, role, None, Some("No such team in this organisation.".into())).await, |
|
| 586 | + | None => return back(&row, Section::Collaborators, None, Some("No such team in this organisation.".into())), |
|
| 390 | 587 | } |
|
| 391 | 588 | } else { |
|
| 392 | - | return settings_page(&app, shell, row, role, None, Some("Name an account or a team.".into())).await; |
|
| 589 | + | return back(&row, Section::Collaborators, None, Some("Name an account or a team.".into())); |
|
| 393 | 590 | }; |
|
| 394 | 591 | if f.remove.is_some() { |
|
| 395 | 592 | accounts::remove_collaborator(&app.db, row.id, account_id, team_id).await.map_err(e)?; |
|
| 396 | 593 | accounts::audit(&app.db, viewer.account(), "repo.collaborator.removed", &format!("{owner}/{name}"), &label).await.ok(); |
|
| 397 | - | return settings_page(&app, shell, row, role, Some(format!("Removed {label}.")), None).await; |
|
| 594 | + | return back(&row, Section::Collaborators, Some(format!("Removed {label}.")), None); |
|
| 398 | 595 | } |
|
| 399 | 596 | let Some(new_role) = Role::parse(&f.role) else { |
|
| 400 | - | return settings_page(&app, shell, row, role, None, Some("Pick a role.".into())).await; |
|
| 597 | + | return back(&row, Section::Collaborators, None, Some("Pick a role.".into())); |
|
| 401 | 598 | }; |
|
| 402 | 599 | accounts::set_collaborator(&app.db, row.id, account_id, team_id, new_role.as_str()).await.map_err(e)?; |
|
| 403 | 600 | accounts::audit( |
|
| … | 5 unchanged lines |
||
| 409 | 606 | ) |
|
| 410 | 607 | .await |
|
| 411 | 608 | .ok(); |
|
| 412 | - | settings_page(&app, shell, row, role, Some(format!("{label} now has {} access.", new_role.as_str())), None).await |
|
| 609 | + | back(&row, Section::Collaborators, Some(format!("{label} now has {} access.", new_role.as_str())), None) |
|
| 413 | 610 | } |
|
| 414 | 611 | ||
| 415 | 612 | #[derive(Deserialize)] |
|
| … | 14 unchanged lines |
||
| 430 | 627 | Form(f): Form<TransferForm>, |
|
| 431 | 628 | ) -> Result<Response, Error> { |
|
| 432 | 629 | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 433 | - | let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 630 | + | let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 434 | 631 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 435 | 632 | let me = viewer.account().cloned().ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 436 | - | let fail = |msg: &str| settings_page(&app, shell.clone(), row.clone(), role, None, Some(msg.into())); |
|
| 633 | + | let fail = |msg: &str| back(&row, Section::Advanced, None, Some(msg.into())); |
|
| 437 | 634 | if f.confirm.trim() != repo.to_string() { |
|
| 438 | - | return Ok(fail("Type the repository's full name to confirm.").await?.into_response()); |
|
| 635 | + | return fail("Type the repository's full name to confirm."); |
|
| 439 | 636 | } |
|
| 440 | 637 | let to = f.owner.trim().trim_start_matches('@').to_string(); |
|
| 441 | 638 | let Some(dest) = RepoName::new(&to, &row.name) else { |
|
| 442 | - | return Ok(fail("That is not an owner name.").await?.into_response()); |
|
| 639 | + | return fail("That is not an owner name."); |
|
| 443 | 640 | }; |
|
| 444 | 641 | if dest.owner == row.owner { |
|
| 445 | - | return Ok(fail("The repository is already there.").await?.into_response()); |
|
| 642 | + | return fail("The repository is already there."); |
|
| 446 | 643 | } |
|
| 447 | 644 | let e = |e| Error::from_db(shell.clone(), e); |
|
| 448 | 645 | let known = accounts::account_by_name(&app.db, &dest.owner).await.map_err(e)?.is_some() |
|
| 449 | 646 | || accounts::org_by_name(&app.db, &dest.owner).await.map_err(e)?.is_some(); |
|
| 450 | 647 | if !known { |
|
| 451 | - | return Ok(fail("There is no user or organisation of that name.").await?.into_response()); |
|
| 648 | + | return fail("There is no user or organisation of that name."); |
|
| 452 | 649 | } |
|
| 453 | 650 | if !roles::may_create_under(&app.db, &me, &dest.owner).await.map_err(e)? { |
|
| 454 | - | return Ok(fail("You cannot create repositories under that owner, so you cannot move one there.").await?.into_response()); |
|
| 651 | + | return fail("You cannot create repositories under that owner, so you cannot move one there."); |
|
| 455 | 652 | } |
|
| 456 | 653 | if db::repos::get(&app.db, &dest.owner, &dest.name).await.map_err(e)?.is_some() || app.repos.exists(&dest) { |
|
| 457 | - | return Ok(fail("That owner already has a repository of this name.").await?.into_response()); |
|
| 654 | + | return fail("That owner already has a repository of this name."); |
|
| 458 | 655 | } |
|
| 459 | 656 | { |
|
| 460 | 657 | let _guard = app.repos.exclusive_guard(&repo).await; |
|
| … | 14 unchanged lines |
||
| 475 | 672 | } |
|
| 476 | 673 | accounts::audit(&app.db, Some(&me), "repo.transferred", &repo.to_string(), &dest.to_string()).await.ok(); |
|
| 477 | 674 | let notice = super::settings::urlencode(&format!("Moved from {repo}; the old address redirects here.")); |
|
| 478 | - | Ok(Redirect::to(&format!("{}/settings?notice={notice}", dest.url())).into_response()) |
|
| 675 | + | Ok(Redirect::to(&format!("{}/settings/{}?notice={notice}", dest.url(), Section::Advanced.slug())).into_response()) |
|
| 479 | 676 | } |
|
| 480 | 677 | ||
| 481 | 678 | #[derive(Deserialize)] |
|
| … | 13 unchanged lines |
||
| 495 | 692 | viewer: Viewer, |
|
| 496 | 693 | Path((owner, name)): Path<(String, String)>, |
|
| 497 | 694 | Form(f): Form<ArchiveForm>, |
|
| 498 | - | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 695 | + | ) -> Result<Response, Error> { |
|
| 499 | 696 | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 500 | - | let (repo, mut row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 697 | + | let (repo, mut row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 501 | 698 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 502 | 699 | let archive = match f.action.as_str() { |
|
| 503 | 700 | "archive" => true, |
|
| 504 | 701 | "unarchive" => false, |
|
| 505 | - | _ => return settings_page(&app, shell, row, role, None, Some("Unknown action.".into())).await, |
|
| 702 | + | _ => return back(&row, Section::Advanced, None, Some("Unknown action.".into())), |
|
| 506 | 703 | }; |
|
| 507 | 704 | let reason: String = f.reason.trim().chars().take(500).collect(); |
|
| 508 | 705 | db::repos::set_archived(&app.db, row.id, archive, &reason).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| … | 7 unchanged lines |
||
| 516 | 713 | } else { |
|
| 517 | 714 | "The repository is back: writes are allowed again." |
|
| 518 | 715 | }; |
|
| 519 | - | settings_page(&app, shell, row, role, Some(notice.into()), None).await |
|
| 716 | + | back(&row, Section::Advanced, Some(notice.into()), None) |
|
| 520 | 717 | } |
|
| 521 | 718 | ||
| 522 | 719 | /// `POST /{owner}/{name}/settings/wiki/delete`: the wiki to the trash (#104). |
|
| … | 2 unchanged lines |
||
| 525 | 722 | viewer: Viewer, |
|
| 526 | 723 | Path((owner, name)): Path<(String, String)>, |
|
| 527 | 724 | Form(f): Form<DeleteForm>, |
|
| 528 | - | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 725 | + | ) -> Result<Response, Error> { |
|
| 529 | 726 | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 530 | - | let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 727 | + | let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 531 | 728 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 532 | 729 | if f.confirm.trim() != repo.to_string() { |
|
| 533 | - | return settings_page(&app, shell, row, role, None, Some("Type the repository's full name to confirm.".into())).await; |
|
| 730 | + | return back(&row, Section::Advanced, None, Some("Type the repository's full name to confirm.".into())); |
|
| 534 | 731 | } |
|
| 535 | 732 | if !crate::wiki::exists(&app, &repo) { |
|
| 536 | - | return settings_page(&app, shell, row, role, None, Some("This repository has no wiki.".into())).await; |
|
| 733 | + | return back(&row, Section::Advanced, None, Some("This repository has no wiki.".into())); |
|
| 537 | 734 | } |
|
| 538 | 735 | let _guard = app.wikis.exclusive_guard(&repo).await; |
|
| 539 | 736 | crate::wiki::trash(&app, &repo).map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 540 | 737 | drop(_guard); |
|
| 541 | 738 | accounts::audit(&app.db, viewer.account(), "wiki.data_deleted", &repo.to_string(), "moved to trash").await.ok(); |
|
| 542 | - | settings_page(&app, shell, row, role, Some("The wiki is in the trash; a new first page starts a fresh one.".into()), None).await |
|
| 739 | + | back(&row, Section::Advanced, Some("The wiki is in the trash; a new first page starts a fresh one.".into()), None) |
|
| 543 | 740 | } |
|
| 544 | 741 | ||
| 545 | 742 | #[derive(Deserialize)] |
|
| … | 10 unchanged lines |
||
| 556 | 753 | Form(f): Form<DeleteForm>, |
|
| 557 | 754 | ) -> Result<Response, Error> { |
|
| 558 | 755 | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 559 | - | let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 756 | + | let (repo, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 560 | 757 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 561 | 758 | if f.confirm.trim() != repo.to_string() { |
|
| 562 | - | return Ok(settings_page(&app, shell, row, role, None, Some("Type the repository's full name to delete it.".into())) |
|
| 563 | - | .await? |
|
| 564 | - | .into_response()); |
|
| 759 | + | return back(&row, Section::Advanced, None, Some("Type the repository's full name to delete it.".into())); |
|
| 565 | 760 | } |
|
| 566 | 761 | let _guard = app.repos.exclusive_guard(&repo).await; |
|
| 567 | 762 | app.repos.trash(&repo).map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| … | 25 unchanged lines |
||
| 593 | 788 | viewer: Viewer, |
|
| 594 | 789 | Path((owner, name)): Path<(String, String)>, |
|
| 595 | 790 | Form(f): Form<RequirementsForm>, |
|
| 596 | - | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 791 | + | ) -> Result<Response, Error> { |
|
| 597 | 792 | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 598 | - | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 793 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 599 | 794 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 600 | 795 | let r = db::proposals::Requirements { |
|
| 601 | 796 | required_approvals: f.required_approvals.max(0), |
|
| … | 10 unchanged lines |
||
| 612 | 807 | ) |
|
| 613 | 808 | .await |
|
| 614 | 809 | .ok(); |
|
| 615 | - | settings_page(&app, shell, row, role, Some("Landing requirements saved.".into()), None).await |
|
| 810 | + | back(&row, Section::Landing, Some("Landing requirements saved.".into()), None) |
|
| 616 | 811 | } |
|
| 617 | 812 | ||
| 618 | 813 | pub async fn hooks( |
|
| … | 1 unchanged lines |
||
| 620 | 815 | viewer: Viewer, |
|
| 621 | 816 | Path((owner, name)): Path<(String, String)>, |
|
| 622 | 817 | Form(f): Form<super::webhooks::HookForm>, |
|
| 623 | - | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 818 | + | ) -> Result<Response, Error> { |
|
| 624 | 819 | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 625 | - | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 820 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 626 | 821 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 627 | 822 | let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") }; |
|
| 628 | 823 | match super::webhooks::apply(&app, &viewer, &scope, f).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 629 | - | Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await, |
|
| 630 | - | Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await, |
|
| 824 | + | Ok(notice) => back(&row, Section::Webhooks, Some(notice), None), |
|
| 825 | + | Err(error) => back(&row, Section::Webhooks, None, Some(error)), |
|
| 631 | 826 | } |
|
| 632 | 827 | } |
|
| 633 | 828 | ||
| … | 35 unchanged lines |
||
| 669 | 864 | viewer: Viewer, |
|
| 670 | 865 | Path((owner, name)): Path<(String, String)>, |
|
| 671 | 866 | Form(f): Form<ChecksForm>, |
|
| 672 | - | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 867 | + | ) -> Result<Response, Error> { |
|
| 673 | 868 | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 674 | - | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 869 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 675 | 870 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 676 | 871 | let names: Vec<String> = f.required_checks.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect(); |
|
| 677 | 872 | db::hooks::set_required_checks(&app.db, row.id, &names).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 678 | - | settings_page(&app, shell, row, role, Some("Required checks saved.".into()), None).await |
|
| 873 | + | back(&row, Section::Checks, Some("Required checks saved.".into()), None) |
|
| 679 | 874 | } |
|
| 680 | 875 | ||
| 681 | 876 | #[derive(Deserialize)] |
|
| … | 12 unchanged lines |
||
| 694 | 889 | viewer: Viewer, |
|
| 695 | 890 | Path((owner, name)): Path<(String, String)>, |
|
| 696 | 891 | Form(f): Form<CoverageForm>, |
|
| 697 | - | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 892 | + | ) -> Result<Response, Error> { |
|
| 698 | 893 | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 699 | - | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 894 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 700 | 895 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 701 | 896 | let number = |v: &str, what: &str, hi: f64| -> Result<Option<f64>, String> { |
|
| 702 | 897 | let v = v.trim().trim_end_matches('%').trim(); |
|
| … | 7 unchanged lines |
||
| 710 | 905 | }; |
|
| 711 | 906 | let rules = match (number(&f.min, "The minimum", 100.0), number(&f.max_drop, "The largest drop", 100.0)) { |
|
| 712 | 907 | (Ok(min), Ok(max_drop)) => crate::coverage::Rules { min, max_drop }, |
|
| 713 | - | (Err(e), _) | (_, Err(e)) => return settings_page(&app, shell, row, role, None, Some(e)).await, |
|
| 908 | + | (Err(e), _) | (_, Err(e)) => return back(&row, Section::Coverage, None, Some(e)), |
|
| 714 | 909 | }; |
|
| 715 | 910 | crate::coverage::set_rules(&app.db, row.id, rules).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 716 | 911 | accounts::audit( |
|
| … | 5 unchanged lines |
||
| 722 | 917 | ) |
|
| 723 | 918 | .await |
|
| 724 | 919 | .ok(); |
|
| 725 | - | settings_page(&app, shell, row, role, Some("Coverage rules saved; they apply from the next report.".into()), None).await |
|
| 920 | + | back(&row, Section::Coverage, Some("Coverage rules saved; they apply from the next report.".into()), None) |
|
| 726 | 921 | } |
|
| 727 | 922 | ||
| 728 | 923 | #[derive(Deserialize)] |
|
| … | 13 unchanged lines |
||
| 742 | 937 | viewer: Viewer, |
|
| 743 | 938 | Path((owner, name)): Path<(String, String)>, |
|
| 744 | 939 | Form(f): Form<StorageForm>, |
|
| 745 | - | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 940 | + | ) -> Result<Response, Error> { |
|
| 746 | 941 | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 747 | - | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 942 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 748 | 943 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 749 | 944 | let Some(s) = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? else { |
|
| 750 | - | return settings_page(&app, shell, row, role, None, Some("This repository is kept on this server's disk.".into())).await; |
|
| 945 | + | return back(&row, Section::General, None, Some("This repository is kept on this server's disk.".into())); |
|
| 751 | 946 | }; |
|
| 752 | 947 | let scheme = s.url.split("://").next().unwrap_or("").to_string(); |
|
| 753 | 948 | let outcome = match f.action.as_str() { |
|
| … | 6 unchanged lines |
||
| 760 | 955 | match outcome { |
|
| 761 | 956 | Ok(notice) => { |
|
| 762 | 957 | accounts::audit(&app.db, viewer.account(), "repo.storage", &format!("{owner}/{name}"), &f.action).await.ok(); |
|
| 763 | - | settings_page(&app, shell, row, role, Some(notice), None).await |
|
| 958 | + | back(&row, Section::Storage, Some(notice), None) |
|
| 764 | 959 | } |
|
| 765 | - | Err(e) => settings_page(&app, shell, row, role, None, Some(format!("Storage: {e}"))).await, |
|
| 960 | + | Err(e) => back(&row, Section::Storage, None, Some(format!("Storage: {e}"))), |
|
| 766 | 961 | } |
|
| 767 | 962 | } |
|
| 768 | 963 | ||
| … | 21 unchanged lines |
||
| 790 | 985 | viewer: Viewer, |
|
| 791 | 986 | Path((owner, name)): Path<(String, String)>, |
|
| 792 | 987 | Form(f): Form<BackupForm>, |
|
| 793 | - | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 988 | + | ) -> Result<Response, Error> { |
|
| 794 | 989 | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 795 | - | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 990 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 796 | 991 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 797 | 992 | if !crate::backups::REPO_CHOICES.iter().any(|(v, _)| *v == f.backup) { |
|
| 798 | - | return settings_page(&app, shell, row, role, None, Some("Pick how this repository is backed up.".into())).await; |
|
| 993 | + | return back(&row, Section::Backup, None, Some("Pick how this repository is backed up.".into())); |
|
| 799 | 994 | } |
|
| 800 | 995 | db::repos::set_backup(&app.db, row.id, &f.backup).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 801 | 996 | let what = if f.backup.is_empty() { "with its owner's" } else { f.backup.as_str() }; |
|
| … | 6 unchanged lines |
||
| 808 | 1003 | format!("This repository is backed up at least {} from now on.", if every == "hourly" { "every hour" } else { "every day" }) |
|
| 809 | 1004 | } |
|
| 810 | 1005 | }; |
|
| 811 | - | settings_page(&app, shell, row, role, Some(notice), None).await |
|
| 1006 | + | back(&row, Section::Backup, Some(notice), None) |
|
| 812 | 1007 | } |
|
| 813 | 1008 | ||
| 814 | 1009 | #[derive(Deserialize)] |
|
| … | 14 unchanged lines |
||
| 829 | 1024 | viewer: Viewer, |
|
| 830 | 1025 | Path((owner, name)): Path<(String, String)>, |
|
| 831 | 1026 | Form(f): Form<DiffusionForm>, |
|
| 832 | - | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 1027 | + | ) -> Result<Response, Error> { |
|
| 833 | 1028 | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 834 | - | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 1029 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 835 | 1030 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 836 | 1031 | let setting = match f.diffusion.as_str() { |
|
| 837 | 1032 | "off" | "green" | "any" => f.diffusion.as_str(), |
|
| 838 | 1033 | "default" => "", |
|
| 839 | - | _ => return settings_page(&app, shell, row, role, None, Some("Pick off, green, any or the site default.".into())).await, |
|
| 1034 | + | _ => return back(&row, Section::Diffusion, None, Some("Pick off, green, any or the site default.".into())), |
|
| 840 | 1035 | }; |
|
| 841 | 1036 | let land = match f.land.as_str() { |
|
| 842 | 1037 | "auto" | "propose" => Some(f.land.as_str()), |
|
| 843 | 1038 | "" => None, |
|
| 844 | - | _ => return settings_page(&app, shell, row, role, None, Some("Pick propose or auto for landing.".into())).await, |
|
| 1039 | + | _ => return back(&row, Section::Diffusion, None, Some("Pick propose or auto for landing.".into())), |
|
| 845 | 1040 | }; |
|
| 846 | 1041 | db::repos::set_diffusion(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 847 | 1042 | if let Some(l) = land { |
|
| … | 19 unchanged lines |
||
| 867 | 1062 | } |
|
| 868 | 1063 | crate::diffusion::Follow::Any => "Diffusion is on: every move of a subrepo's upstream opens or updates a proposal here.", |
|
| 869 | 1064 | }; |
|
| 870 | - | settings_page(&app, shell, row, role, Some(notice.into()), None).await |
|
| 1065 | + | back(&row, Section::Diffusion, Some(notice.into()), None) |
|
| 871 | 1066 | } |
|
| 872 | 1067 | ||
| 873 | 1068 | #[derive(Deserialize)] |
|
| … | 13 unchanged lines |
||
| 887 | 1082 | viewer: Viewer, |
|
| 888 | 1083 | Path((owner, name)): Path<(String, String)>, |
|
| 889 | 1084 | Form(f): Form<AgentGatesForm>, |
|
| 890 | - | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 1085 | + | ) -> Result<Response, Error> { |
|
| 891 | 1086 | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 892 | - | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 1087 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 893 | 1088 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 894 | 1089 | let on = f.agents == "on"; |
|
| 895 | 1090 | db::repos::set_agents_decide_gates(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| … | 6 unchanged lines |
||
| 902 | 1097 | } else { |
|
| 903 | 1098 | "Approval gates here are decided by people: agents' tokens are refused." |
|
| 904 | 1099 | }; |
|
| 905 | - | settings_page(&app, shell, row, role, Some(notice.into()), None).await |
|
| 1100 | + | back(&row, Section::Gates, Some(notice.into()), None) |
|
| 906 | 1101 | } |
|
| 907 | 1102 | ||
| 908 | 1103 | #[derive(Deserialize)] |
|
| … | 13 unchanged lines |
||
| 922 | 1117 | viewer: Viewer, |
|
| 923 | 1118 | Path((owner, name)): Path<(String, String)>, |
|
| 924 | 1119 | Form(f): Form<PushProtectionForm>, |
|
| 925 | - | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 1120 | + | ) -> Result<Response, Error> { |
|
| 926 | 1121 | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 927 | - | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 1122 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 928 | 1123 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 929 | 1124 | let on = f.protect == "on"; |
|
| 930 | 1125 | db::push_protection::set_enabled(&app.db, row.id, on).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| … | 10 unchanged lines |
||
| 941 | 1136 | } else { |
|
| 942 | 1137 | "Push protection is off: pushes are no longer scanned for secrets here." |
|
| 943 | 1138 | }; |
|
| 944 | - | settings_page(&app, shell, row, role, Some(notice.into()), None).await |
|
| 1139 | + | back(&row, Section::PushProtection, Some(notice.into()), None) |
|
| 945 | 1140 | } |
|
| 946 | 1141 | ||
| 947 | 1142 | #[derive(Deserialize)] |
|
| … | 13 unchanged lines |
||
| 961 | 1156 | viewer: Viewer, |
|
| 962 | 1157 | Path((owner, name)): Path<(String, String)>, |
|
| 963 | 1158 | Form(f): Form<SecretBypassForm>, |
|
| 964 | - | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 1159 | + | ) -> Result<Response, Error> { |
|
| 965 | 1160 | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 966 | - | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 1161 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 967 | 1162 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 968 | 1163 | let fingerprint = f.fingerprint.trim().to_ascii_lowercase(); |
|
| 969 | 1164 | let reason = f.reason.trim(); |
|
| … | 7 unchanged lines |
||
| 977 | 1172 | None |
|
| 978 | 1173 | }; |
|
| 979 | 1174 | if let Some(e) = error { |
|
| 980 | - | return settings_page(&app, shell, row, role, None, Some(e.into())).await; |
|
| 1175 | + | return back(&row, Section::PushProtection, None, Some(e.into())); |
|
| 981 | 1176 | } |
|
| 982 | 1177 | let rule = db::push_protection::rule_of(&app.db, row.id, &fingerprint) |
|
| 983 | 1178 | .await |
|
| … | 4 unchanged lines |
||
| 988 | 1183 | let detail = if rule.is_empty() { format!("{fingerprint}: {reason}") } else { format!("{fingerprint} ({rule}): {reason}") }; |
|
| 989 | 1184 | accounts::audit(&app.db, viewer.account(), "secret.bypass", &format!("{owner}/{name}"), &detail).await.ok(); |
|
| 990 | 1185 | let notice = format!("Fingerprint {fingerprint} is allowed here: a push adding that value is no longer refused for it."); |
|
| 991 | - | settings_page(&app, shell, row, role, Some(notice), None).await |
|
| 1186 | + | back(&row, Section::PushProtection, Some(notice), None) |
|
| 992 | 1187 | } |
|
| 993 | 1188 | ||
| 994 | 1189 | #[derive(Deserialize)] |
|
| … | 11 unchanged lines |
||
| 1006 | 1201 | viewer: Viewer, |
|
| 1007 | 1202 | Path((owner, name)): Path<(String, String)>, |
|
| 1008 | 1203 | Form(f): Form<BypassRemoveForm>, |
|
| 1009 | - | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 1204 | + | ) -> Result<Response, Error> { |
|
| 1010 | 1205 | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 1011 | - | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 1206 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 1012 | 1207 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 1013 | 1208 | let fingerprint = f.fingerprint.trim().to_ascii_lowercase(); |
|
| 1014 | 1209 | let removed = db::push_protection::remove_bypass(&app.db, row.id, &fingerprint).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 1015 | 1210 | let Some(b) = removed else { |
|
| 1016 | - | return settings_page(&app, shell, row, role, None, Some(format!("Fingerprint {fingerprint} is not allowed here."))).await; |
|
| 1211 | + | return back(&row, Section::PushProtection, None, Some(format!("Fingerprint {fingerprint} is not allowed here."))); |
|
| 1017 | 1212 | }; |
|
| 1018 | 1213 | let detail = if b.rule.is_empty() { |
|
| 1019 | 1214 | format!("{fingerprint} (was: {})", b.reason) |
|
| … | 2 unchanged lines |
||
| 1022 | 1217 | }; |
|
| 1023 | 1218 | accounts::audit(&app.db, viewer.account(), "secret.bypass_removed", &format!("{owner}/{name}"), &detail).await.ok(); |
|
| 1024 | 1219 | let notice = format!("Fingerprint {fingerprint} is no longer allowed: a push adding that value is refused again."); |
|
| 1025 | - | settings_page(&app, shell, row, role, Some(notice), None).await |
|
| 1220 | + | back(&row, Section::PushProtection, Some(notice), None) |
|
| 1026 | 1221 | } |
|
| 1027 | 1222 | ||
| 1028 | 1223 | #[derive(Deserialize)] |
|
| … | 13 unchanged lines |
||
| 1042 | 1237 | viewer: Viewer, |
|
| 1043 | 1238 | Path((owner, name)): Path<(String, String)>, |
|
| 1044 | 1239 | Form(f): Form<SecretResolveForm>, |
|
| 1045 | - | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 1240 | + | ) -> Result<Response, Error> { |
|
| 1046 | 1241 | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 1047 | - | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 1242 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 1048 | 1243 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 1049 | 1244 | let reason = f.reason.trim(); |
|
| 1050 | 1245 | let error = if reason.is_empty() { |
|
| … | 4 unchanged lines |
||
| 1055 | 1250 | None |
|
| 1056 | 1251 | }; |
|
| 1057 | 1252 | if let Some(e) = error { |
|
| 1058 | - | return settings_page(&app, shell, row, role, None, Some(e.into())).await; |
|
| 1253 | + | return back(&row, Section::PushProtection, None, Some(e.into())); |
|
| 1059 | 1254 | } |
|
| 1060 | 1255 | let by = viewer.account().map(|a| a.username.as_str()).unwrap_or(""); |
|
| 1061 | 1256 | let done = db::push_protection::resolve(&app.db, row.id, f.finding, by, reason).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 1062 | 1257 | let Some(found) = done else { |
|
| 1063 | - | return settings_page(&app, shell, row, role, None, Some("That finding is not open here.".into())).await; |
|
| 1258 | + | return back(&row, Section::PushProtection, None, Some("That finding is not open here.".into())); |
|
| 1064 | 1259 | }; |
|
| 1065 | 1260 | let detail = format!("{} ({}) at {}:{}: {reason}", found.fingerprint, found.rule, found.path, found.line); |
|
| 1066 | 1261 | accounts::audit(&app.db, viewer.account(), "secret.resolved", &format!("{owner}/{name}"), &detail).await.ok(); |
|
| 1067 | 1262 | let notice = format!("The finding at {}:{} is marked resolved.", found.path, found.line); |
|
| 1068 | - | settings_page(&app, shell, row, role, Some(notice), None).await |
|
| 1263 | + | back(&row, Section::PushProtection, Some(notice), None) |
|
| 1069 | 1264 | } |
|
| 1070 | 1265 | ||
| 1071 | 1266 | #[derive(Deserialize)] |
|
| … | 10 unchanged lines |
||
| 1082 | 1277 | viewer: Viewer, |
|
| 1083 | 1278 | Path((owner, name)): Path<(String, String)>, |
|
| 1084 | 1279 | Form(f): Form<SecretScanForm>, |
|
| 1085 | - | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 1280 | + | ) -> Result<Response, Error> { |
|
| 1086 | 1281 | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 1087 | - | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 1282 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 1088 | 1283 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 1089 | 1284 | if !row.push_protection { |
|
| 1090 | - | return settings_page( |
|
| 1091 | - | &app, |
|
| 1092 | - | shell, |
|
| 1093 | - | row, |
|
| 1094 | - | role, |
|
| 1285 | + | return back( |
|
| 1286 | + | &row, |
|
| 1287 | + | Section::PushProtection, |
|
| 1095 | 1288 | None, |
|
| 1096 | 1289 | Some("Turn push protection on first: history is scanned only where it is on.".into()), |
|
| 1097 | - | ) |
|
| 1098 | - | .await; |
|
| 1290 | + | ); |
|
| 1099 | 1291 | } |
|
| 1100 | 1292 | crate::history_scan::request(&app, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 1101 | 1293 | accounts::audit(&app.db, viewer.account(), "secret.history_scan", &format!("{owner}/{name}"), "requested").await.ok(); |
|
| 1102 | 1294 | let notice = "The whole history will be scanned again, before other repositories: findings appear below as they are found."; |
|
| 1103 | - | settings_page(&app, shell, row, role, Some(notice.into()), None).await |
|
| 1295 | + | back(&row, Section::PushProtection, Some(notice.into()), None) |
|
| 1104 | 1296 | } |
|
| 1105 | 1297 | ||
| 1106 | 1298 | #[derive(Deserialize)] |
|
| … | 14 unchanged lines |
||
| 1121 | 1313 | viewer: Viewer, |
|
| 1122 | 1314 | Path((owner, name)): Path<(String, String)>, |
|
| 1123 | 1315 | Form(f): Form<SupersedeForm>, |
|
| 1124 | - | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 1316 | + | ) -> Result<Response, Error> { |
|
| 1125 | 1317 | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 1126 | - | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 1318 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 1127 | 1319 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 1128 | 1320 | let keep = f.running != "on"; |
|
| 1129 | 1321 | db::repos::set_flow_keep_running(&app.db, row.id, keep).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| … | 6 unchanged lines |
||
| 1136 | 1328 | } else { |
|
| 1137 | 1329 | "A new push or proposal run now cancels the older runs it supersedes, queued or running." |
|
| 1138 | 1330 | }; |
|
| 1139 | - | settings_page(&app, shell, row, role, Some(notice.into()), None).await |
|
| 1331 | + | back(&row, Section::Runs, Some(notice.into()), None) |
|
| 1140 | 1332 | } |
|
| 1141 | 1333 | ||
| 1142 | 1334 | #[derive(Deserialize)] |
|
| … | 12 unchanged lines |
||
| 1155 | 1347 | viewer: Viewer, |
|
| 1156 | 1348 | Path((owner, name)): Path<(String, String)>, |
|
| 1157 | 1349 | Form(f): Form<PriorityForm>, |
|
| 1158 | - | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 1350 | + | ) -> Result<Response, Error> { |
|
| 1159 | 1351 | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 1160 | - | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 1352 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 1161 | 1353 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 1162 | 1354 | let level = match f.priority.as_str() { |
|
| 1163 | 1355 | "low" | "normal" | "high" => f.priority.as_str(), |
|
| 1164 | 1356 | "default" => "", |
|
| 1165 | - | _ => return settings_page(&app, shell, row, role, None, Some("Pick low, normal, high or the default.".into())).await, |
|
| 1357 | + | _ => return back(&row, Section::Runs, None, Some("Pick low, normal, high or the default.".into())), |
|
| 1166 | 1358 | }; |
|
| 1167 | 1359 | if level == "high" && !viewer.is_admin() { |
|
| 1168 | - | return settings_page(&app, shell, row, role, None, Some("Only site administrators raise a repository to high.".into())).await; |
|
| 1360 | + | return back(&row, Section::Runs, None, Some("Only site administrators raise a repository to high.".into())); |
|
| 1169 | 1361 | } |
|
| 1170 | 1362 | db::repos::set_flow_priority(&app.db, row.id, level).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 1171 | 1363 | accounts::audit( |
|
| … | 11 unchanged lines |
||
| 1183 | 1375 | } else { |
|
| 1184 | 1376 | format!("Its jobs are {level} priority.") |
|
| 1185 | 1377 | }; |
|
| 1186 | - | settings_page(&app, shell, row, role, Some(notice), None).await |
|
| 1378 | + | back(&row, Section::Runs, Some(notice), None) |
|
| 1187 | 1379 | } |
|
| 1188 | 1380 | ||
| 1189 | 1381 | #[derive(Deserialize)] |
|
| … | 13 unchanged lines |
||
| 1203 | 1395 | viewer: Viewer, |
|
| 1204 | 1396 | Path((owner, name)): Path<(String, String)>, |
|
| 1205 | 1397 | Form(f): Form<FlowForm>, |
|
| 1206 | - | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 1398 | + | ) -> Result<Response, Error> { |
|
| 1207 | 1399 | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 1208 | - | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 1400 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 1209 | 1401 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 1210 | 1402 | let setting = match f.flow.as_str() { |
|
| 1211 | 1403 | "on" => "on", |
|
| 1212 | 1404 | "own" => "own", |
|
| 1213 | 1405 | "off" => "off", |
|
| 1214 | 1406 | "default" => "", |
|
| 1215 | - | _ => return settings_page(&app, shell, row, role, None, Some("Pick on, own runners, off or the site default.".into())).await, |
|
| 1407 | + | _ => return back(&row, Section::Pipelines, None, Some("Pick on, own runners, off or the site default.".into())), |
|
| 1216 | 1408 | }; |
|
| 1217 | 1409 | if !viewer.is_admin() { |
|
| 1218 | 1410 | if matches!(setting, "on" | "") { |
|
| 1219 | - | return settings_page( |
|
| 1220 | - | &app, |
|
| 1221 | - | shell, |
|
| 1222 | - | row, |
|
| 1223 | - | role, |
|
| 1411 | + | return back( |
|
| 1412 | + | &row, |
|
| 1413 | + | Section::Pipelines, |
|
| 1224 | 1414 | None, |
|
| 1225 | 1415 | Some("Only site administrators let the site's runners take this repository's jobs.".into()), |
|
| 1226 | - | ) |
|
| 1227 | - | .await; |
|
| 1416 | + | ); |
|
| 1228 | 1417 | } |
|
| 1229 | 1418 | if setting == "own" && !has_own_runner(&app, &row).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 1230 | - | return settings_page( |
|
| 1231 | - | &app, |
|
| 1232 | - | shell, |
|
| 1233 | - | row, |
|
| 1234 | - | role, |
|
| 1419 | + | return back( |
|
| 1420 | + | &row, |
|
| 1421 | + | Section::Pipelines, |
|
| 1235 | 1422 | None, |
|
| 1236 | - | Some("Register a runner for this repository (Runners, below) or its owner first.".into()), |
|
| 1237 | - | ) |
|
| 1238 | - | .await; |
|
| 1423 | + | Some("Register a runner for this repository (Settings › Runners) or its owner first.".into()), |
|
| 1424 | + | ); |
|
| 1239 | 1425 | } |
|
| 1240 | 1426 | } |
|
| 1241 | 1427 | db::repos::set_flow(&app.db, row.id, setting).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| … | 7 unchanged lines |
||
| 1249 | 1435 | } else { |
|
| 1250 | 1436 | "OxygenFlow is off for this repository." |
|
| 1251 | 1437 | }; |
|
| 1252 | - | settings_page(&app, shell, row, role, Some(notice.into()), None).await |
|
| 1438 | + | back(&row, Section::Pipelines, Some(notice.into()), None) |
|
| 1253 | 1439 | } |
|
| 1254 | 1440 | ||
| 1255 | 1441 | pub async fn mirror( |
|
| … | 1 unchanged lines |
||
| 1257 | 1443 | viewer: Viewer, |
|
| 1258 | 1444 | Path((owner, name)): Path<(String, String)>, |
|
| 1259 | 1445 | Form(f): Form<MirrorForm>, |
|
| 1260 | - | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 1446 | + | ) -> Result<Response, Error> { |
|
| 1261 | 1447 | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 1262 | - | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 1448 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?; |
|
| 1263 | 1449 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 1264 | 1450 | db::repos::set_git_mirror(&app.db, row.id, f.enabled.is_some()).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 1265 | 1451 | let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row); |
|
| … | 5 unchanged lines |
||
| 1271 | 1457 | .ok(); |
|
| 1272 | 1458 | let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row); |
|
| 1273 | 1459 | let notice = if row.git_mirror { "The Git mirror is on and up to date." } else { "The Git mirror is off." }; |
|
| 1274 | - | settings_page(&app, shell, row, role, Some(notice.into()), None).await |
|
| 1460 | + | back(&row, Section::GitMirror, Some(notice.into()), None) |
|
| 1275 | 1461 | } |
|
| 1276 | 1462 | ||
| 1277 | 1463 | /// `POST /{owner}/{name}/settings/runners/token`: a one-time registration |
|
| … | 14 unchanged lines |
||
| 1292 | 1478 | .await |
|
| 1293 | 1479 | .map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 1294 | 1480 | accounts::audit(&app.db, viewer.account(), "flow.runner.token", &row.slug(), "registration token for this repository").await.ok(); |
|
| 1295 | - | let mut page = settings_page(&app, shell, row, role, Some("Copy the token now; it is not shown again.".into()), None).await?; |
|
| 1481 | + | let mut page = |
|
| 1482 | + | settings_page(&app, shell, row, role, Section::Runners, Some("Copy the token now; it is not shown again.".into()), None).await?; |
|
| 1296 | 1483 | page.0.runner_token = Some(token); |
|
| 1297 | 1484 | Ok(page) |
|
| 1298 | 1485 | } |
|
| … | 5 unchanged lines |
||
| 1304 | 1491 | viewer: Viewer, |
|
| 1305 | 1492 | Path((owner, name, id)): Path<(String, String, i64)>, |
|
| 1306 | 1493 | Form(f): Form<super::session::CsrfForm>, |
|
| 1307 | - | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 1494 | + | ) -> Result<Response, Error> { |
|
| 1308 | 1495 | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 1309 | - | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 1496 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 1310 | 1497 | csrf(&viewer, &f.csrf, &shell)?; |
|
| 1311 | 1498 | let e = |e| Error::from_db(shell.clone(), e); |
|
| 1312 | 1499 | let Some(r) = db::runners::get(&app.db, id).await.map_err(e)?.filter(|r| r.scope_repo_id == Some(row.id)) else { |
|
| 1313 | 1500 | return Err(Error::not_found(shell)); |
|
| 1314 | 1501 | }; |
|
| 1315 | 1502 | if db::runners::running(&app.db).await.map_err(e)?.iter().any(|(rid, n)| *rid == id && *n > 0) { |
|
| 1316 | - | return settings_page(&app, shell, row, role, None, Some(format!("{} is running jobs: wait for them to finish.", r.name))).await; |
|
| 1503 | + | return back(&row, Section::Runners, None, Some(format!("{} is running jobs: wait for them to finish.", r.name))); |
|
| 1317 | 1504 | } |
|
| 1318 | 1505 | db::runners::remove(&app.db, id).await.map_err(e)?; |
|
| 1319 | 1506 | accounts::audit(&app.db, viewer.account(), "flow.runner.removed", &r.name, &row.slug()).await.ok(); |
|
| 1320 | - | settings_page(&app, shell, row, role, Some(format!("{} removed: its credential no longer works.", r.name)), None).await |
|
| 1507 | + | back(&row, Section::Runners, Some(format!("{} removed: its credential no longer works.", r.name)), None) |
|
| 1321 | 1508 | } |
|
| 1322 | 1509 | ||
| 1323 | 1510 | fn repo_assistants(row: &db::repos::RepoRow) -> crate::levels::At { |
|
| … | 7 unchanged lines |
||
| 1331 | 1518 | viewer: Viewer, |
|
| 1332 | 1519 | Path((owner, name)): Path<(String, String)>, |
|
| 1333 | 1520 | Form(f): Form<super::assistants::AssistantForm>, |
|
| 1334 | - | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 1521 | + | ) -> Result<Response, Error> { |
|
| 1335 | 1522 | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 1336 | - | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 1523 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 1337 | 1524 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 1338 | 1525 | match super::assistants::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 1339 | - | Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await, |
|
| 1340 | - | Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await, |
|
| 1526 | + | Ok(notice) => back(&row, Section::Assistants, Some(notice), None), |
|
| 1527 | + | Err(error) => back(&row, Section::Assistants, None, Some(error)), |
|
| 1341 | 1528 | } |
|
| 1342 | 1529 | } |
|
| 1343 | 1530 | ||
| … | 4 unchanged lines |
||
| 1348 | 1535 | viewer: Viewer, |
|
| 1349 | 1536 | Path((owner, name)): Path<(String, String)>, |
|
| 1350 | 1537 | Form(f): Form<super::variables::VariableForm>, |
|
| 1351 | - | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 1538 | + | ) -> Result<Response, Error> { |
|
| 1352 | 1539 | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 1353 | - | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 1540 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 1354 | 1541 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 1355 | 1542 | match super::variables::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 1356 | - | Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await, |
|
| 1357 | - | Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await, |
|
| 1543 | + | Ok(notice) => back(&row, Section::Variables, Some(notice), None), |
|
| 1544 | + | Err(error) => back(&row, Section::Variables, None, Some(error)), |
|
| 1358 | 1545 | } |
|
| 1359 | 1546 | } |
|
| 1360 | 1547 | ||
| … | 4 unchanged lines |
||
| 1365 | 1552 | viewer: Viewer, |
|
| 1366 | 1553 | Path((owner, name)): Path<(String, String)>, |
|
| 1367 | 1554 | Form(f): Form<super::secrets::SecretForm>, |
|
| 1368 | - | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 1555 | + | ) -> Result<Response, Error> { |
|
| 1369 | 1556 | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 1370 | - | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 1557 | + | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 1371 | 1558 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 1372 | 1559 | match super::secrets::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 1373 | - | Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await, |
|
| 1374 | - | Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await, |
|
| 1560 | + | Ok(notice) => back(&row, Section::Secrets, Some(notice), None), |
|
| 1561 | + | Err(error) => back(&row, Section::Secrets, None, Some(error)), |
|
| 1375 | 1562 | } |
|
| 1376 | 1563 | } |
|
| 1377 | 1564 | ||
| … | 18 unchanged lines |
modified crates/hub/src/web/webhooks.rs +12 −1
| … | 29 unchanged lines |
||
| 30 | 30 | } |
|
| 31 | 31 | } |
|
| 32 | 32 | ||
| 33 | + | /// Where its webhooks are listed: a repository's own settings page |
|
| 34 | + | /// (#278), an organisation's section. |
|
| 35 | + | pub fn webhooks_url(&self) -> String { |
|
| 36 | + | match self { |
|
| 37 | + | Scope::Repo { slug, .. } => format!("/{slug}/settings/webhooks"), |
|
| 38 | + | Scope::Org { name, .. } => format!("/orgs/{name}/settings#webhooks"), |
|
| 39 | + | } |
|
| 40 | + | } |
|
| 41 | + | ||
| 33 | 42 | fn label(&self) -> &str { |
|
| 34 | 43 | match self { |
|
| 35 | 44 | Scope::Repo { slug, .. } => slug, |
|
| … | 152 unchanged lines |
||
| 188 | 197 | hook: db::hooks::Hook, |
|
| 189 | 198 | rows: Vec<db::hooks::Delivery>, |
|
| 190 | 199 | settings_url: String, |
|
| 200 | + | /// The page listing the webhooks, to go back to. |
|
| 201 | + | back_url: String, |
|
| 191 | 202 | } |
|
| 192 | 203 | ||
| 193 | 204 | pub async fn deliveries_page( |
|
| … | 9 unchanged lines |
||
| 203 | 214 | .filter(|h| scope.owns(h)) |
|
| 204 | 215 | .ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 205 | 216 | let rows = db::hooks::deliveries_for_hook(&app.db, id, 100).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 206 | - | Ok(Page(DeliveriesPage { shell, repo, hook, rows, settings_url: scope.settings_url() })) |
|
| 217 | + | Ok(Page(DeliveriesPage { shell, repo, hook, rows, settings_url: scope.settings_url(), back_url: scope.webhooks_url() })) |
|
| 207 | 218 | } |
|
| 208 | 219 | ||
| 209 | 220 | pub async fn redeliver(app: &Shared, scope: &Scope, id: i64, delivery: i64) -> sqlx::Result<()> { |
|
| … | 7 unchanged lines |
modified crates/hub/tests/accounts.rs +123 −4
| … | 97 unchanged lines |
||
| 98 | 98 | send(app, "POST", path, cookie, Some(form)).await |
|
| 99 | 99 | } |
|
| 100 | 100 | ||
| 101 | + | /// A repository settings form's answer (#278): a redirect to the page that |
|
| 102 | + | /// holds the form, followed. |
|
| 103 | + | async fn settled(app: &Shared, r: Res, cookie: Option<&str>, page: &str) -> Res { |
|
| 104 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 105 | + | let to = r.location.expect("a redirect"); |
|
| 106 | + | assert!(to.starts_with(&format!("{page}?")), "{to} is not {page}"); |
|
| 107 | + | get(app, &to, cookie).await |
|
| 108 | + | } |
|
| 109 | + | ||
| 101 | 110 | /// Register (or sign in) and return the session cookie value. |
|
| 102 | 111 | async fn register(app: &Shared, user: &str, invite: &str) -> String { |
|
| 103 | 112 | let r = post( |
|
| … | 91 unchanged lines |
||
| 195 | 204 | assert_eq!(settings.status, StatusCode::OK); |
|
| 196 | 205 | let csrf = csrf_of(&settings.body); |
|
| 197 | 206 | let r = post(app, "/andy/secret/settings/collaborators", Some(&andy), &format!("_csrf={csrf}&username=bob&role=write")).await; |
|
| 198 | - | assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
|
| 207 | + | let r = settled(app, r, Some(&andy), "/andy/secret/settings/collaborators").await; |
|
| 208 | + | assert!(r.body.contains("bob now has write access."), "{}", r.body); |
|
| 199 | 209 | assert_eq!(get(app, "/andy/secret", Some(&bob)).await.status, StatusCode::OK); |
|
| 200 | 210 | assert_eq!(get(app, "/andy/secret/settings", Some(&bob)).await.status, StatusCode::NOT_FOUND, "write is not maintain"); |
|
| 201 | 211 | std::env::set_var("OXY_TOKEN", &bob_tok); |
|
| … | 182 unchanged lines |
||
| 384 | 394 | let mut out = Vec::new(); |
|
| 385 | 395 | img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); |
|
| 386 | 396 | out |
|
| 397 | + | } |
|
| 398 | + | ||
| 399 | + | /// Repository settings (oxygenhub#278): one page per setting beside a |
|
| 400 | + | /// grouped sidebar that marks the page shown; a role sees only its pages |
|
| 401 | + | /// and the others are not found; each form returns to the page holding it. |
|
| 402 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 403 | + | async fn repository_settings_are_a_page_each_by_role() { |
|
| 404 | + | let h = hub().await; |
|
| 405 | + | let app = &h.app; |
|
| 406 | + | let andy = register(app, "andy", "").await; |
|
| 407 | + | let mut invited = vec![]; |
|
| 408 | + | for name in ["bob", "carol"] { |
|
| 409 | + | let csrf = csrf_of(&get(app, "/admin", Some(&andy)).await.body); |
|
| 410 | + | let r = post(app, "/admin/invite", Some(&andy), &format!("_csrf={csrf}")).await; |
|
| 411 | + | let code = r.body.split("register?invite=").nth(1).unwrap().split('<').next().unwrap().trim().to_string(); |
|
| 412 | + | invited.push(register(app, name, &code).await); |
|
| 413 | + | } |
|
| 414 | + | let (bob, carol) = (invited[0].clone(), invited[1].clone()); |
|
| 415 | + | let csrf = csrf_of(&get(app, "/new", Some(&andy)).await.body); |
|
| 416 | + | let r = post(app, "/new", Some(&andy), &format!("_csrf={csrf}&owner=andy&name=demo&visibility=public")).await; |
|
| 417 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 418 | + | let csrf = csrf_of(&get(app, "/andy/demo/settings", Some(&andy)).await.body); |
|
| 419 | + | for (who, role) in [("bob", "maintain"), ("carol", "write")] { |
|
| 420 | + | let r = post(app, "/andy/demo/settings/collaborators", Some(&andy), &format!("_csrf={csrf}&username={who}&role={role}")).await; |
|
| 421 | + | let page = settled(app, r, Some(&andy), "/andy/demo/settings/collaborators").await; |
|
| 422 | + | assert!(page.body.contains(&format!("{who} now has {role} access.")), "{}", page.body); |
|
| 423 | + | } |
|
| 424 | + | ||
| 425 | + | let marked = |slug: &str| format!("href=\"/andy/demo/settings/{slug}\" data-slug=\"{slug}\" class=\"on\" aria-current=\"page\""); |
|
| 426 | + | let maintainers = |
|
| 427 | + | ["general", "landing", "checks", "push-protection", "flow", "runs", "gates", "coverage", "git-mirror", "diffusion", "backup"]; |
|
| 428 | + | let admins = ["collaborators", "runners", "variables", "secrets", "webhooks", "assistants", "advanced"]; |
|
| 429 | + | // /settings is General, marked in the sidebar, with the groups' headings. |
|
| 430 | + | let general = get(app, "/andy/demo/settings", Some(&andy)).await; |
|
| 431 | + | assert_eq!(general.status, StatusCode::OK); |
|
| 432 | + | assert!(general.body.contains(&marked("general")) && general.body.contains("id=\"general\""), "{}", general.body); |
|
| 433 | + | for group in ["General", "Code", "Flow", "Integrations", "Data", "Danger zone"] { |
|
| 434 | + | assert!(general.body.contains(&format!("<h2>{group}</h2>")), "group {group}"); |
|
| 435 | + | } |
|
| 436 | + | assert!(general.body.contains("data-settings-anchors"), "kept #anchor links are sent on from General"); |
|
| 437 | + | // Every page renders for an admin, marked; one page's sections only. |
|
| 438 | + | for slug in maintainers.iter().chain(admins.iter()) { |
|
| 439 | + | let page = get(app, &format!("/andy/demo/settings/{slug}"), Some(&andy)).await; |
|
| 440 | + | assert_eq!(page.status, StatusCode::OK, "{slug}"); |
|
| 441 | + | assert!(page.body.contains(&marked(slug)), "{slug} is marked: {}", page.body); |
|
| 442 | + | for other in maintainers.iter().chain(admins.iter()).filter(|o| *o != slug) { |
|
| 443 | + | assert!(!page.body.contains(&marked(other)), "{slug} marks {other}"); |
|
| 444 | + | } |
|
| 445 | + | } |
|
| 446 | + | let runs = get(app, "/andy/demo/settings/runs", Some(&andy)).await.body; |
|
| 447 | + | assert!(runs.contains("id=\"flow-priority\"") && runs.contains("id=\"flow-supersede\"") && !runs.contains("id=\"general\"")); |
|
| 448 | + | // Storage is only for a repository kept in a bucket. |
|
| 449 | + | assert_eq!(get(app, "/andy/demo/settings/storage", Some(&andy)).await.status, StatusCode::NOT_FOUND); |
|
| 450 | + | assert!(!general.body.contains("/andy/demo/settings/storage\"")); |
|
| 451 | + | assert_eq!(get(app, "/andy/demo/settings/nope", Some(&andy)).await.status, StatusCode::NOT_FOUND); |
|
| 452 | + | ||
| 453 | + | // A maintainer has the maintainers' pages only; the admins' are not |
|
| 454 | + | // listed and not found. |
|
| 455 | + | let page = get(app, "/andy/demo/settings", Some(&bob)).await; |
|
| 456 | + | assert_eq!(page.status, StatusCode::OK); |
|
| 457 | + | for slug in maintainers { |
|
| 458 | + | assert_eq!(get(app, &format!("/andy/demo/settings/{slug}"), Some(&bob)).await.status, StatusCode::OK, "{slug}"); |
|
| 459 | + | assert!(page.body.contains(&format!("data-slug=\"{slug}\"")), "{slug} listed"); |
|
| 460 | + | } |
|
| 461 | + | for slug in admins { |
|
| 462 | + | assert_eq!(get(app, &format!("/andy/demo/settings/{slug}"), Some(&bob)).await.status, StatusCode::NOT_FOUND, "{slug}"); |
|
| 463 | + | assert!(!page.body.contains(&format!("data-slug=\"{slug}\"")), "{slug} not listed"); |
|
| 464 | + | } |
|
| 465 | + | assert!(!page.body.contains("<h2>Danger zone</h2>"), "an empty group is left out"); |
|
| 466 | + | let bob_csrf = csrf_of(&page.body); |
|
| 467 | + | let r = post(app, "/andy/demo/settings/flow-priority", Some(&bob), &format!("_csrf={bob_csrf}&priority=normal")).await; |
|
| 468 | + | let back = settled(app, r, Some(&bob), "/andy/demo/settings/runs").await; |
|
| 469 | + | assert!(back.body.contains("Its jobs are normal priority.") && back.body.contains(&marked("runs")), "{}", back.body); |
|
| 470 | + | // A writer has none of them. |
|
| 471 | + | assert_eq!(get(app, "/andy/demo/settings", Some(&carol)).await.status, StatusCode::NOT_FOUND); |
|
| 472 | + | for slug in ["general", "push-protection", "advanced"] { |
|
| 473 | + | assert_eq!(get(app, &format!("/andy/demo/settings/{slug}"), Some(&carol)).await.status, StatusCode::NOT_FOUND, "{slug}"); |
|
| 474 | + | } |
|
| 475 | + | ||
| 476 | + | // Each form returns to its own page, with what happened. |
|
| 477 | + | for (path, form, page, says) in [ |
|
| 478 | + | ("", "description=Demo&default_bookmark=main&visibility=public&topics=", "general", "Saved."), |
|
| 479 | + | ("/requirements", "required_approvals=1", "landing", "Landing requirements saved."), |
|
| 480 | + | ("/checks", "required_checks=build", "checks", "Required checks saved."), |
|
| 481 | + | ("/push-protection", "", "push-protection", "Push protection is off"), |
|
| 482 | + | ("/push-protection", "protect=on", "push-protection", "Push protection is on"), |
|
| 483 | + | ("/secret-bypass", "fingerprint=0123456789abcdef&reason=a+test+fixture", "push-protection", "is allowed here"), |
|
| 484 | + | ("/flow", "flow=sometimes", "flow", "Pick on, own runners, off or the site default."), |
|
| 485 | + | ("/flow-priority", "priority=low", "runs", "Its jobs are low priority."), |
|
| 486 | + | ("/flow-supersede", "running=on", "runs", "cancels the older runs it supersedes, queued or running"), |
|
| 487 | + | ("/agent-gates", "agents=on", "gates", "may now approve or reject"), |
|
| 488 | + | ("/variables", "action=set&name=RUST_LOG&value=debug", "variables", "RUST_LOG set."), |
|
| 489 | + | ("/coverage", "min=80&max_drop=", "coverage", "Coverage rules saved"), |
|
| 490 | + | ("/hooks", "action=delete&id=999", "webhooks", "Webhook deleted."), |
|
| 491 | + | ("/mirror", "", "git-mirror", "The Git mirror is off."), |
|
| 492 | + | ("/diffusion", "diffusion=off&land=propose", "diffusion", "Diffusion is off"), |
|
| 493 | + | ("/backup", "backup=off", "backup", "left out of its owner"), |
|
| 494 | + | ("/archive", "action=neither", "advanced", "Unknown action."), |
|
| 495 | + | ] { |
|
| 496 | + | let r = post(app, &format!("/andy/demo/settings{path}"), Some(&andy), &format!("_csrf={csrf}&{form}")).await; |
|
| 497 | + | let back = settled(app, r, Some(&andy), &format!("/andy/demo/settings/{page}")).await; |
|
| 498 | + | assert_eq!(back.status, StatusCode::OK, "{path}"); |
|
| 499 | + | assert!(back.body.contains(says) && back.body.contains(&marked(page)), "{path}: {}", back.body); |
|
| 500 | + | } |
|
| 501 | + | ||
| 502 | + | // A kept /settings#anchor link is sent on by hub.js, never inline. |
|
| 503 | + | let js = get(app, "/static/hub.js", None).await.body; |
|
| 504 | + | assert!(js.contains("[data-settings-anchors]") && js.contains("\"flow-priority\": \"runs\""), "{js}"); |
|
| 387 | 505 | } |
|
| 388 | 506 | ||
| 389 | 507 | #[tokio::test(flavor = "multi_thread")] |
|
| … | 109 unchanged lines |
||
| 499 | 617 | assert!(db::hooks::hook(&app.db, repo_hook).await.unwrap().is_some(), "not the organisation's"); |
|
| 500 | 618 | assert_eq!(get(app, &format!("/orgs/acme/settings/hooks/{repo_hook}/deliveries"), Some(&andy)).await.status, StatusCode::NOT_FOUND); |
|
| 501 | 619 | // The repository's own settings still manage its hooks (shared code). |
|
| 502 | - | let rs = get(app, "/andy/demo/settings", Some(&andy)).await; |
|
| 620 | + | let rs = get(app, "/andy/demo/settings/webhooks", Some(&andy)).await; |
|
| 503 | 621 | assert!(rs.body.contains("https://hooks.invalid/repo") && rs.body.contains("action=\"/andy/demo/settings/hooks\"")); |
|
| 504 | 622 | ||
| 505 | 623 | // Storage: sizes and loose objects, and maintenance packs them. |
|
| … | 319 unchanged lines |
||
| 825 | 943 | assert!(r.body.contains("DeepSeek added and answered"), "{}", r.body); |
|
| 826 | 944 | let prompt = assistants::prompt_for_repo(&app.db, &repo).await.unwrap(); |
|
| 827 | 945 | assert!(prompt.starts_with("## Instructions from the site\n\nReview for correctness first.\n\n## Instructions from the acme organisation\n\nWe write 6502 assembly."), "{prompt}"); |
|
| 828 | - | let repo_page = get(app, "/acme/plans/settings", Some(&andy)).await.body; |
|
| 946 | + | let repo_page = get(app, "/acme/plans/settings/assistants", Some(&andy)).await.body; |
|
| 829 | 947 | assert!(repo_page.contains("from the acme organisation") && repo_page.contains("From the acme organisation"), "{repo_page}"); |
|
| 830 | 948 | let on = assistants::enabled_for_repo(&app.db, &repo).await.unwrap(); |
|
| 831 | 949 | assert_eq!(on.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(), ["Claude", "DeepSeek"]); |
|
| … | 4 unchanged lines |
||
| 836 | 954 | let r = post(app, "/admin/assistants", Some(&andy), &format!("_csrf={csrf}&action=toggle&id={}", claude.id)).await; |
|
| 837 | 955 | assert!(r.body.contains("Claude is off."), "{}", r.body); |
|
| 838 | 956 | let r = post(app, "/acme/plans/settings/assistants", Some(&andy), &add("Claude", "")).await; |
|
| 957 | + | let r = settled(app, r, Some(&andy), "/acme/plans/settings/assistants").await; |
|
| 839 | 958 | assert!(r.body.contains("Claude added and answered"), "{}", r.body); |
|
| 840 | 959 | let on = assistants::enabled_for_repo(&app.db, &repo).await.unwrap(); |
|
| 841 | 960 | assert_eq!(on.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(), ["DeepSeek"]); |
|
| 842 | - | let repo_page = get(app, "/acme/plans/settings", Some(&andy)).await.body; |
|
| 961 | + | let repo_page = get(app, "/acme/plans/settings/assistants", Some(&andy)).await.body; |
|
| 843 | 962 | assert!(repo_page.contains("off above"), "{repo_page}"); |
|
| 844 | 963 | post(app, "/admin/assistants", Some(&andy), &format!("_csrf={csrf}&action=toggle&id={}", claude.id)).await; |
|
| 845 | 964 | let on = assistants::enabled_for_repo(&app.db, &repo).await.unwrap(); |
|
| … | 759 unchanged lines |
modified crates/hub/tests/api.rs +27 −15
| … | 60 unchanged lines |
||
| 61 | 61 | status: StatusCode, |
|
| 62 | 62 | body: String, |
|
| 63 | 63 | cookie: Option<String>, |
|
| 64 | + | location: Option<String>, |
|
| 64 | 65 | } |
|
| 65 | 66 | ||
| 66 | 67 | async fn call( |
|
| … | 30 unchanged lines |
||
| 97 | 98 | .and_then(|v| v.split(';').next()) |
|
| 98 | 99 | .and_then(|kv| kv.split_once('=')) |
|
| 99 | 100 | .map(|(_, v)| v.to_string()); |
|
| 101 | + | let location = res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).map(String::from); |
|
| 100 | 102 | let bytes = res.into_body().collect().await.unwrap().to_bytes(); |
|
| 101 | - | Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie } |
|
| 103 | + | Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie, location } |
|
| 102 | 104 | } |
|
| 103 | 105 | ||
| 106 | + | /// A repository settings form's answer (#278): a redirect to the page that |
|
| 107 | + | /// holds the form, followed. |
|
| 108 | + | async fn settled(app: &Shared, r: Res, cookie: Option<&str>, page: &str) -> Res { |
|
| 109 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 110 | + | let to = r.location.expect("a redirect"); |
|
| 111 | + | assert!(to.starts_with(&format!("{page}?")), "{to} is not {page}"); |
|
| 112 | + | call(app, "GET", &to, None, cookie, None, None).await |
|
| 113 | + | } |
|
| 114 | + | ||
| 104 | 115 | async fn api(app: &Shared, method: &str, path: &str, bearer: Option<&str>, json: Option<&str>) -> (StatusCode, serde_json::Value) { |
|
| 105 | 116 | let r = call(app, method, path, bearer, None, json, None).await; |
|
| 106 | 117 | let v = serde_json::from_str(&r.body).unwrap_or(serde_json::Value::String(r.body.clone())); |
|
| … | 222 unchanged lines |
||
| 329 | 340 | // Require the "build" check. |
|
| 330 | 341 | let settings = call(app, "GET", "/andy/demo/settings", None, Some(&andy), None, None).await; |
|
| 331 | 342 | let csrf = csrf_of(&settings.body); |
|
| 332 | - | assert_eq!( |
|
| 333 | - | call(app, "POST", "/andy/demo/settings/checks", None, Some(&andy), None, Some(&format!("_csrf={csrf}&required_checks=build"))) |
|
| 334 | - | .await |
|
| 335 | - | .status, |
|
| 336 | - | StatusCode::OK |
|
| 337 | - | ); |
|
| 343 | + | let r = call(app, "POST", "/andy/demo/settings/checks", None, Some(&andy), None, Some(&format!("_csrf={csrf}&required_checks=build"))) |
|
| 344 | + | .await; |
|
| 345 | + | assert!(settled(app, r, Some(&andy), "/andy/demo/settings/checks").await.body.contains("Required checks saved.")); |
|
| 338 | 346 | let (_, v) = api(app, "GET", "/api/v1/repos/andy/demo/proposals/2", None, None).await; |
|
| 339 | 347 | assert_eq!(v["landable"], false); |
|
| 340 | 348 | assert!(v["blockers"][0].as_str().unwrap().contains("build"), "{v}"); |
|
| … | 403 unchanged lines |
||
| 744 | 752 | let csrf = csrf_of(&settings); |
|
| 745 | 753 | let form = |f: String| { |
|
| 746 | 754 | let andy = andy.clone(); |
|
| 747 | - | async move { call(app, "POST", "/andy/demo/settings/hooks", None, Some(&andy), None, Some(&f)).await.body } |
|
| 755 | + | async move { |
|
| 756 | + | let r = call(app, "POST", "/andy/demo/settings/hooks", None, Some(&andy), None, Some(&f)).await; |
|
| 757 | + | settled(app, r, Some(&andy), "/andy/demo/settings/webhooks").await.body |
|
| 758 | + | } |
|
| 748 | 759 | }; |
|
| 749 | 760 | let url = rx.url.replace(':', "%3A").replace('/', "%2F"); |
|
| 750 | 761 | assert!(form(format!("_csrf={csrf}&url={url}&format=discord&events=*")).await.contains("Webhook added")); |
|
| … | 37 unchanged lines |
||
| 788 | 799 | let v: serde_json::Value = serde_json::from_str(&got).unwrap(); |
|
| 789 | 800 | assert_eq!(v["embeds"][0]["title"], "OxygenHub is connected"); |
|
| 790 | 801 | assert_eq!(v["allowed_mentions"]["parse"], serde_json::json!([])); |
|
| 791 | - | let page = call(app, "GET", "/andy/demo/settings", None, Some(&andy), None, None).await.body; |
|
| 802 | + | let page = call(app, "GET", "/andy/demo/settings/webhooks", None, Some(&andy), None, None).await.body; |
|
| 792 | 803 | assert!(page.contains("last delivery <span class=\"sig sig-valid\">✔</span> 200"), "the table shows how it went"); |
|
| 793 | 804 | ||
| 794 | 805 | // Editing: a blank URL and secret keep theirs; format, events and active change. |
|
| … | 188 unchanged lines |
||
| 983 | 994 | StatusCode::BAD_REQUEST |
|
| 984 | 995 | ); |
|
| 985 | 996 | // The settings page shows the same list. |
|
| 986 | - | assert!(call(app, "GET", "/andy/demo/settings", None, Some(&andy), None, None).await.body.contains("flow/fmt, flow/test")); |
|
| 997 | + | assert!(call(app, "GET", "/andy/demo/settings/checks", None, Some(&andy), None, None).await.body.contains("flow/fmt, flow/test")); |
|
| 987 | 998 | } |
|
| 988 | 999 | ||
| 989 | 1000 | /// OxygenFlow per repository: site administrators turn it on or off in |
|
| … | 19 unchanged lines |
||
| 1009 | 1020 | let row = || async { oxygenhub::db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap() }; |
|
| 1010 | 1021 | assert!(!oxygenhub::flow::enabled(app, &row().await), "not in [flow] repos"); |
|
| 1011 | 1022 | let tab = call(app, "GET", "/andy/demo/flow", None, Some(&andy), None, None).await; |
|
| 1012 | - | assert!(tab.body.contains("not enabled") && tab.body.contains("/andy/demo/settings#flow")); |
|
| 1023 | + | assert!(tab.body.contains("not enabled") && tab.body.contains("/andy/demo/settings/flow")); |
|
| 1013 | 1024 | ||
| 1014 | 1025 | let set = |value: &'static str| { |
|
| 1015 | 1026 | let (app, andy) = (app.clone(), andy.clone()); |
|
| 1016 | 1027 | async move { |
|
| 1017 | - | let settings = call(&app, "GET", "/andy/demo/settings", None, Some(&andy), None, None).await; |
|
| 1018 | - | call( |
|
| 1028 | + | let settings = call(&app, "GET", "/andy/demo/settings/flow", None, Some(&andy), None, None).await; |
|
| 1029 | + | let r = call( |
|
| 1019 | 1030 | &app, |
|
| 1020 | 1031 | "POST", |
|
| 1021 | 1032 | "/andy/demo/settings/flow", |
|
| … | 2 unchanged lines |
||
| 1024 | 1035 | None, |
|
| 1025 | 1036 | Some(&format!("_csrf={}&flow={value}", csrf_of(&settings.body))), |
|
| 1026 | 1037 | ) |
|
| 1027 | - | .await |
|
| 1038 | + | .await; |
|
| 1039 | + | settled(&app, r, Some(&andy), "/andy/demo/settings/flow").await |
|
| 1028 | 1040 | } |
|
| 1029 | 1041 | }; |
|
| 1030 | 1042 | // A repository admin who is not a site admin sees the state but cannot change it. |
|
| 1031 | 1043 | let id = oxygenhub::db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id; |
|
| 1032 | 1044 | oxygenhub::db::accounts::set_admin(&app.db, id, false).await.unwrap(); |
|
| 1033 | - | let settings = call(app, "GET", "/andy/demo/settings", None, Some(&andy), None, None).await; |
|
| 1045 | + | let settings = call(app, "GET", "/andy/demo/settings/flow", None, Some(&andy), None, None).await; |
|
| 1034 | 1046 | assert!(settings.body.contains("do not start runs") && !settings.body.contains("name=\"flow\"")); |
|
| 1035 | 1047 | let r = set("on").await; |
|
| 1036 | 1048 | assert!(r.body.contains("Only site administrators"), "{}", r.body); |
|
| … | 617 unchanged lines |
modified crates/hub/tests/edits.rs +20 −5
| … | 83 unchanged lines |
||
| 84 | 84 | } |
|
| 85 | 85 | } |
|
| 86 | 86 | ||
| 87 | + | /// A repository settings form's answer (#278): a redirect to the page that |
|
| 88 | + | /// holds the form, followed. |
|
| 89 | + | async fn settled(app: &Shared, r: Res, cookie: Option<&str>, page: &str) -> Res { |
|
| 90 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 91 | + | let to = r.location.expect("a redirect"); |
|
| 92 | + | assert!(to.starts_with(&format!("{page}?")), "{to} is not {page}"); |
|
| 93 | + | send(app, "GET", &to, cookie, None).await |
|
| 94 | + | } |
|
| 95 | + | ||
| 87 | 96 | fn csrf_of(page: &str) -> String { |
|
| 88 | 97 | let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len(); |
|
| 89 | 98 | page[i..].split('"').next().unwrap().to_string() |
|
| … | 150 unchanged lines |
||
| 240 | 249 | let page = send(app, "GET", "/andy/demo/proposal/2", Some(&andy), None).await; |
|
| 241 | 250 | assert!(page.body.contains("ready to land") && page.body.contains(">LAND<"), "{}", page.body); |
|
| 242 | 251 | // Nobody approved, and the repository requires no approval: the page says so and Land asks first. |
|
| 243 | - | assert!(page.body.contains("settings</a> require none."), "{}", page.body); |
|
| 252 | + | assert!(page.body.contains("/andy/demo/settings/landing\">landing rules</a> require none."), "{}", page.body); |
|
| 244 | 253 | assert!(page.body.contains("without any approval?"), "{}", page.body); |
|
| 245 | 254 | let r = send(app, "POST", "/andy/demo/proposal/2/land", Some(&andy), Some(&format!("_csrf={}", csrf_of(&page.body)))).await; |
|
| 246 | 255 | // The action redirects back (so a reload never repeats it), saying so. |
|
| … | 382 unchanged lines |
||
| 629 | 638 | assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 630 | 639 | ||
| 631 | 640 | // The Advanced section, and a transfer with the wrong confirmation. |
|
| 632 | - | let settings = send(app, "GET", "/andy/vk/settings", Some(&andy), None).await.body; |
|
| 641 | + | let settings = send(app, "GET", "/andy/vk/settings/advanced", Some(&andy), None).await.body; |
|
| 633 | 642 | assert!( |
|
| 634 | 643 | settings.contains("Advanced") && settings.contains("Transfer ownership") && settings.contains("Delete wiki data"), |
|
| 635 | 644 | "{settings}" |
|
| 636 | 645 | ); |
|
| 637 | 646 | let csrf = csrf_of(&settings); |
|
| 638 | 647 | let r = send(app, "POST", "/andy/vk/settings/transfer", Some(&andy), Some(&format!("_csrf={csrf}&owner=doomsday&confirm=wrong"))).await; |
|
| 648 | + | let r = settled(app, r, Some(&andy), "/andy/vk/settings/advanced").await; |
|
| 639 | 649 | assert!(r.body.contains("full name to confirm"), "{}", r.body); |
|
| 640 | 650 | let r = |
|
| 641 | 651 | send(app, "POST", "/andy/vk/settings/transfer", Some(&andy), Some(&format!("_csrf={csrf}&owner=nobody&confirm=andy%2Fvk"))).await; |
|
| 652 | + | let r = settled(app, r, Some(&andy), "/andy/vk/settings/advanced").await; |
|
| 642 | 653 | assert!(r.body.contains("no user or organisation"), "{}", r.body); |
|
| 643 | 654 | // Moved to the organisation: the new page works, the old URL redirects |
|
| 644 | 655 | // with the rest of its path, and the client's old remote still pushes. |
|
| 645 | 656 | let r = |
|
| 646 | 657 | send(app, "POST", "/andy/vk/settings/transfer", Some(&andy), Some(&format!("_csrf={csrf}&owner=doomsday&confirm=andy%2Fvk"))).await; |
|
| 647 | - | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 658 | + | let moved = settled(app, r, Some(&andy), "/doomsday/vk/settings/advanced").await; |
|
| 659 | + | assert!(moved.body.contains("Moved from andy/vk"), "{}", moved.body); |
|
| 648 | 660 | let page = send(app, "GET", "/doomsday/vk", None, None).await; |
|
| 649 | 661 | assert_eq!(page.status, StatusCode::OK); |
|
| 650 | 662 | assert!(page.body.contains("a port"), "{}", page.body); |
|
| … | 8 unchanged lines |
||
| 659 | 671 | assert!(audit.iter().any(|a| a.action == "repo.transferred" && a.target == "andy/vk" && a.detail == "doomsday/vk")); |
|
| 660 | 672 | ||
| 661 | 673 | // Archived: pushes, issues and comments are refused; reading works; back again. |
|
| 662 | - | let settings = send(app, "GET", "/doomsday/vk/settings", Some(&andy), None).await.body; |
|
| 674 | + | let settings = send(app, "GET", "/doomsday/vk/settings/advanced", Some(&andy), None).await.body; |
|
| 663 | 675 | let csrf = csrf_of(&settings); |
|
| 664 | 676 | let r = |
|
| 665 | 677 | send(app, "POST", "/doomsday/vk/settings/archive", Some(&andy), Some(&format!("_csrf={csrf}&action=archive&reason=Superseded"))) |
|
| 666 | 678 | .await; |
|
| 679 | + | let r = settled(app, r, Some(&andy), "/doomsday/vk/settings/advanced").await; |
|
| 667 | 680 | assert!(r.body.contains("is archived") && r.body.contains("Bring this repository back") && r.body.contains("Superseded"), "{}", r.body); |
|
| 668 | 681 | assert!(send(app, "GET", "/doomsday/vk", None, None).await.body.contains(">archived<")); |
|
| 669 | 682 | write(work.path(), "a.txt", b"one\ntwo\nthree\n"); |
|
| … | 4 unchanged lines |
||
| 674 | 687 | let r = send(app, "POST", "/doomsday/vk/issues/new", Some(&andy), Some(&format!("_csrf={csrf}&title=x&body=y"))).await; |
|
| 675 | 688 | assert!(r.body.contains("archived and read-only"), "{} {}", r.status, r.body); |
|
| 676 | 689 | let r = send(app, "POST", "/doomsday/vk/settings/archive", Some(&andy), Some(&format!("_csrf={csrf}&action=unarchive"))).await; |
|
| 690 | + | let r = settled(app, r, Some(&andy), "/doomsday/vk/settings/advanced").await; |
|
| 677 | 691 | assert!(r.body.contains("The repository is back"), "{}", r.body); |
|
| 678 | 692 | assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 679 | 693 | } |
|
| … | 126 unchanged lines |
||
| 806 | 820 | let r = send(app, "POST", "/andy/demo/wiki/Guide/delete", Some(&andy), Some(&format!("_csrf={csrf}"))).await; |
|
| 807 | 821 | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 808 | 822 | assert!(send(app, "GET", "/andy/demo/wiki/Guide", None, None).await.body.contains("no page called")); |
|
| 809 | - | let settings = send(app, "GET", "/andy/demo/settings", Some(&andy), None).await.body; |
|
| 823 | + | let settings = send(app, "GET", "/andy/demo/settings/advanced", Some(&andy), None).await.body; |
|
| 810 | 824 | assert!(settings.contains("DELETE WIKI DATA"), "{settings}"); |
|
| 811 | 825 | let r = send( |
|
| 812 | 826 | app, |
|
| … | 3 unchanged lines |
||
| 816 | 830 | Some(&format!("_csrf={}&confirm=andy%2Fdemo", csrf_of(&settings))), |
|
| 817 | 831 | ) |
|
| 818 | 832 | .await; |
|
| 833 | + | let r = settled(app, r, Some(&andy), "/andy/demo/settings/advanced").await; |
|
| 819 | 834 | assert!(r.body.contains("The wiki is in the trash"), "{}", r.body); |
|
| 820 | 835 | assert!(send(app, "GET", "/andy/demo/wiki", Some(&andy), None).await.body.contains("No wiki yet")); |
|
| 821 | 836 | } |
|
| … | 49 unchanged lines |
modified crates/hub/tests/flow.rs +39 −15
| … | 1589 unchanged lines |
||
| 1590 | 1590 | let i = page.find("name=\"_csrf\" value=\"").unwrap() + "name=\"_csrf\" value=\"".len(); |
|
| 1591 | 1591 | page[i..].split('"').next().unwrap().to_string() |
|
| 1592 | 1592 | }; |
|
| 1593 | - | let (st, page) = web("GET", "/andy/demo/settings".into(), None).await; |
|
| 1593 | + | let (st, page) = web("GET", "/andy/demo/settings/runners".into(), None).await; |
|
| 1594 | 1594 | assert_eq!(st, StatusCode::OK); |
|
| 1595 | - | assert!(page.contains("<h2>Runners</h2>"), "the owner sees the section"); |
|
| 1595 | + | assert!(page.contains("<h2>Runners</h2>"), "the owner sees the page"); |
|
| 1596 | 1596 | let (_, page) = web("POST", "/andy/demo/settings/runners/token".into(), Some(format!("_csrf={}", csrf_of(&page)))).await; |
|
| 1597 | 1597 | let i = page.find("--token ").unwrap() + "--token ".len(); |
|
| 1598 | 1598 | let token: String = page[i..].chars().take_while(|c| !c.is_whitespace()).collect(); |
|
| … | 7 unchanged lines |
||
| 1606 | 1606 | // The site's runner (hub.toml, from its first claim) serves it too, but is not the owner's to remove. |
|
| 1607 | 1607 | claim(&h.app).await; |
|
| 1608 | 1608 | let site = db::runners::list(&h.app.db).await.unwrap().into_iter().find(|r| r.name == "test-1").unwrap(); |
|
| 1609 | - | let (_, page) = web("GET", "/andy/demo/settings".into(), None).await; |
|
| 1609 | + | let (_, page) = web("GET", "/andy/demo/settings/runners".into(), None).await; |
|
| 1610 | 1610 | assert!(page.contains("<strong>mine</strong>") && page.contains("this repository"), "{page}"); |
|
| 1611 | 1611 | assert!(page.contains("<strong>test-1</strong>") && page.contains("the whole site"), "{page}"); |
|
| 1612 | 1612 | let csrf = csrf_of(&page); |
|
| 1613 | 1613 | let (st, _) = web("POST", format!("/andy/demo/settings/runners/{}/remove", site.id), Some(format!("_csrf={csrf}"))).await; |
|
| 1614 | 1614 | assert_eq!(st, StatusCode::NOT_FOUND, "not the repository's own"); |
|
| 1615 | - | let (_, page) = web("POST", format!("/andy/demo/settings/runners/{}/remove", mine.id), Some(format!("_csrf={csrf}"))).await; |
|
| 1616 | - | assert!(page.contains("mine removed"), "{page}"); |
|
| 1615 | + | let (st, _) = web("POST", format!("/andy/demo/settings/runners/{}/remove", mine.id), Some(format!("_csrf={csrf}"))).await; |
|
| 1616 | + | assert_eq!(st, StatusCode::SEE_OTHER, "back to the Runners page"); |
|
| 1617 | + | let (_, page) = web("GET", "/andy/demo/settings/runners?notice=x".into(), None).await; |
|
| 1618 | + | assert!(!page.contains("<strong>mine</strong>"), "{page}"); |
|
| 1617 | 1619 | assert!(db::runners::get(&h.app.db, mine.id).await.unwrap().is_none()); |
|
| 1618 | 1620 | ||
| 1619 | 1621 | // The same for an organisation's owner. |
|
| … | 454 unchanged lines |
||
| 2074 | 2076 | req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); |
|
| 2075 | 2077 | } |
|
| 2076 | 2078 | let req = req.body(Body::from(form.unwrap_or_default())).unwrap(); |
|
| 2077 | - | let app = app.clone(); |
|
| 2079 | + | let (app, cookie) = (app.clone(), cookie.clone()); |
|
| 2078 | 2080 | async move { |
|
| 2079 | - | let res = oxygenhub::router(app).oneshot(req).await.unwrap(); |
|
| 2081 | + | let mut res = oxygenhub::router(app.clone()).oneshot(req).await.unwrap(); |
|
| 2082 | + | // A repository settings form returns to its page (#278). |
|
| 2083 | + | let back = |
|
| 2084 | + | res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).filter(|l| l.contains("/settings/")).map(String::from); |
|
| 2085 | + | if let Some(to) = back { |
|
| 2086 | + | let req = Request::get(to).header(header::COOKIE, cookie).body(Body::empty()).unwrap(); |
|
| 2087 | + | res = oxygenhub::router(app).oneshot(req).await.unwrap(); |
|
| 2088 | + | } |
|
| 2080 | 2089 | let status = res.status(); |
|
| 2081 | 2090 | let body = String::from_utf8(res.into_body().collect().await.unwrap().to_bytes().to_vec()).unwrap(); |
|
| 2082 | 2091 | (status, body) |
|
| … | 10 unchanged lines |
||
| 2093 | 2102 | variables::set(&app.db, Scope::Site, "LANG", "C").await.unwrap(); |
|
| 2094 | 2103 | let org = db::accounts::org_by_name(&app.db, "acme").await.unwrap().unwrap(); |
|
| 2095 | 2104 | variables::set(&app.db, Scope::Org(org.id), "CC", "clang").await.unwrap(); |
|
| 2096 | - | let (st, settings) = page("/acme/app/settings", None).await; |
|
| 2105 | + | let (st, settings) = page("/acme/app/settings/variables", None).await; |
|
| 2097 | 2106 | assert_eq!(st, StatusCode::OK, "{settings}"); |
|
| 2098 | 2107 | assert!(settings.contains("Inherited") && settings.contains("the acme organisation") && settings.contains("overridden"), "{settings}"); |
|
| 2099 | 2108 | let csrf = csrf_of(&settings); |
|
| … | 54 unchanged lines |
||
| 2154 | 2163 | req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); |
|
| 2155 | 2164 | } |
|
| 2156 | 2165 | let req = req.body(Body::from(form.unwrap_or_default())).unwrap(); |
|
| 2157 | - | let app = app.clone(); |
|
| 2166 | + | let (app, cookie) = (app.clone(), cookie.clone()); |
|
| 2158 | 2167 | async move { |
|
| 2159 | - | let res = oxygenhub::router(app).oneshot(req).await.unwrap(); |
|
| 2168 | + | let mut res = oxygenhub::router(app.clone()).oneshot(req).await.unwrap(); |
|
| 2169 | + | // A repository settings form returns to its page (#278). |
|
| 2170 | + | let back = |
|
| 2171 | + | res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).filter(|l| l.contains("/settings/")).map(String::from); |
|
| 2172 | + | if let Some(to) = back { |
|
| 2173 | + | let req = Request::get(to).header(header::COOKIE, cookie).body(Body::empty()).unwrap(); |
|
| 2174 | + | res = oxygenhub::router(app).oneshot(req).await.unwrap(); |
|
| 2175 | + | } |
|
| 2160 | 2176 | let status = res.status(); |
|
| 2161 | 2177 | let body = String::from_utf8(res.into_body().collect().await.unwrap().to_bytes().to_vec()).unwrap(); |
|
| 2162 | 2178 | (status, body) |
|
| … | 12 unchanged lines |
||
| 2175 | 2191 | assert!(body.contains("deploy-token set.") && !body.contains("site-level-value"), "{body}"); |
|
| 2176 | 2192 | let (_, body) = page("/admin/secrets", Some(format!("_csrf={csrf}&action=set&name=bad+name&value=x"))).await; |
|
| 2177 | 2193 | assert!(body.contains("name is 1 to 64 of"), "{body}"); |
|
| 2178 | - | let (_, settings) = page("/andy/demo/settings", None).await; |
|
| 2194 | + | let (_, settings) = page("/andy/demo/settings/secrets", None).await; |
|
| 2179 | 2195 | assert!( |
|
| 2180 | 2196 | settings.contains("Secrets") && settings.contains("<code>deploy-token</code>") && settings.contains("the site</td>"), |
|
| 2181 | 2197 | "{settings}" |
|
| … | 2170 unchanged lines |
||
| 4352 | 4368 | let web = |method: &'static str, path: &str, body: Option<String>| { |
|
| 4353 | 4369 | let (app, cookie, path) = (h.app.clone(), bob.clone(), path.to_string()); |
|
| 4354 | 4370 | async move { |
|
| 4355 | - | let mut req = Request::builder().method(method).uri(path).header(header::COOKIE, cookie); |
|
| 4371 | + | let mut req = Request::builder().method(method).uri(path).header(header::COOKIE, cookie.clone()); |
|
| 4356 | 4372 | if body.is_some() { |
|
| 4357 | 4373 | req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); |
|
| 4358 | 4374 | } |
|
| 4359 | - | let res = oxygenhub::router(app).oneshot(req.body(body.map(Body::from).unwrap_or_else(Body::empty)).unwrap()).await.unwrap(); |
|
| 4375 | + | let req = req.body(body.map(Body::from).unwrap_or_else(Body::empty)).unwrap(); |
|
| 4376 | + | let mut res = oxygenhub::router(app.clone()).oneshot(req).await.unwrap(); |
|
| 4377 | + | // A repository settings form returns to its page (#278). |
|
| 4378 | + | let back = |
|
| 4379 | + | res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).filter(|l| l.contains("/settings/")).map(String::from); |
|
| 4380 | + | if let Some(to) = back { |
|
| 4381 | + | let req = Request::get(to).header(header::COOKIE, cookie).body(Body::empty()).unwrap(); |
|
| 4382 | + | res = oxygenhub::router(app).oneshot(req).await.unwrap(); |
|
| 4383 | + | } |
|
| 4360 | 4384 | String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned() |
|
| 4361 | 4385 | } |
|
| 4362 | 4386 | }; |
|
| … | 2 unchanged lines |
||
| 4365 | 4389 | page[i..].split('"').next().unwrap().to_string() |
|
| 4366 | 4390 | }; |
|
| 4367 | 4391 | // No runner of his own yet: he is told how, and cannot switch it on. |
|
| 4368 | - | let page = web("GET", "/bob/game/settings", None).await; |
|
| 4392 | + | let page = web("GET", "/bob/game/settings/flow", None).await; |
|
| 4369 | 4393 | assert!(page.contains("register a runner for this repository") && !page.contains("value=\"own\""), "{page}"); |
|
| 4370 | 4394 | let page = web("POST", "/bob/game/settings/flow", Some(format!("_csrf={}&flow=own", csrf_of(&page)))).await; |
|
| 4371 | 4395 | assert!(page.contains("Register a runner for this repository"), "{page}"); |
|
| … | 9 unchanged lines |
||
| 4381 | 4405 | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 4382 | 4406 | let credential = serde_json::from_str::<serde_json::Value>(&body).unwrap()["credential"].as_str().unwrap().to_string(); |
|
| 4383 | 4407 | // The site's runners stay a site administrator's to grant; his own he may choose. |
|
| 4384 | - | let page = web("GET", "/bob/game/settings", None).await; |
|
| 4408 | + | let page = web("GET", "/bob/game/settings/flow", None).await; |
|
| 4385 | 4409 | assert!(page.contains("value=\"own\"") && !page.contains("value=\"on\""), "{page}"); |
|
| 4386 | 4410 | let page = web("POST", "/bob/game/settings/flow", Some(format!("_csrf={}&flow=on", csrf_of(&page)))).await; |
|
| 4387 | 4411 | assert!(page.contains("Only site administrators let the site's runners"), "{page}"); |
|
| … | 1680 unchanged lines |
modified crates/hub/tests/history_scan.rs +24 −8
| … | 70 unchanged lines |
||
| 71 | 71 | status: StatusCode, |
|
| 72 | 72 | body: String, |
|
| 73 | 73 | cookie: Option<String>, |
|
| 74 | + | location: Option<String>, |
|
| 74 | 75 | } |
|
| 75 | 76 | ||
| 76 | 77 | async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, form: Option<&str>) -> Res { |
|
| … | 17 unchanged lines |
||
| 94 | 95 | .and_then(|v| v.split(';').next()) |
|
| 95 | 96 | .and_then(|kv| kv.split_once('=')) |
|
| 96 | 97 | .map(|(_, v)| v.to_string()); |
|
| 98 | + | let location = res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).map(String::from); |
|
| 97 | 99 | let bytes = res.into_body().collect().await.unwrap().to_bytes(); |
|
| 98 | - | Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie } |
|
| 100 | + | Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie, location } |
|
| 99 | 101 | } |
|
| 100 | 102 | ||
| 103 | + | /// A repository settings form's answer (#278): a redirect to the page that |
|
| 104 | + | /// holds the form, followed. |
|
| 105 | + | async fn settled(app: &Shared, r: Res, cookie: Option<&str>, page: &str) -> Res { |
|
| 106 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 107 | + | let to = r.location.expect("a redirect"); |
|
| 108 | + | assert!(to.starts_with(&format!("{page}?")), "{to} is not {page}"); |
|
| 109 | + | send(app, "GET", &to, cookie, None).await |
|
| 110 | + | } |
|
| 111 | + | ||
| 101 | 112 | async fn get(app: &Shared, path: &str, cookie: Option<&str>) -> Res { |
|
| 102 | 113 | send(app, "GET", path, cookie, None).await |
|
| 103 | 114 | } |
|
| … | 100 unchanged lines |
||
| 204 | 215 | assert!(p.idle && p.changes == 0, "{p:?}"); |
|
| 205 | 216 | ||
| 206 | 217 | // Maintainers see it under Found in history, never the secret. |
|
| 207 | - | let page = get(app, "/andy/demo/settings", Some(&andy)).await; |
|
| 218 | + | let page = get(app, "/andy/demo/settings/push-protection", Some(&andy)).await; |
|
| 208 | 219 | assert!(page.body.contains("Found in history") && page.body.contains(&fp), "{}", page.body); |
|
| 209 | 220 | assert!(page.body.contains(&format!("/andy/demo/change/{}", leak.to_hex())) && page.body.contains("deploy/pay.env:2")); |
|
| 210 | 221 | assert!(!page.body.contains(&key), "the settings page shows no secret"); |
|
| … | 22 unchanged lines |
||
| 233 | 244 | // A writer can neither resolve nor remove an allowed fingerprint. |
|
| 234 | 245 | let bob = register(app, "bob").await; |
|
| 235 | 246 | let r = post(app, "/andy/demo/settings/collaborators", Some(&andy), &format!("_csrf={csrf}&username=bob&role=write")).await; |
|
| 236 | - | assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
|
| 247 | + | let r = settled(app, r, Some(&andy), "/andy/demo/settings/collaborators").await; |
|
| 248 | + | assert!(r.body.contains("bob now has write access."), "{}", r.body); |
|
| 237 | 249 | let bob_csrf = csrf_of(&get(app, "/andy/demo", Some(&bob)).await.body); |
|
| 238 | 250 | let stripe_id = found.iter().find(|f| f.fingerprint == fp).unwrap().id; |
|
| 239 | 251 | let r = post(app, "/andy/demo/settings/secret-resolve", Some(&bob), &format!("_csrf={bob_csrf}&finding={stripe_id}&reason=x")).await; |
|
| … | 3 unchanged lines |
||
| 243 | 255 | ||
| 244 | 256 | // Resolved: the key was revoked. A reason is needed; audited. |
|
| 245 | 257 | let r = post(app, "/andy/demo/settings/secret-resolve", Some(&andy), &format!("_csrf={csrf}&finding={stripe_id}&reason=")).await; |
|
| 258 | + | let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await; |
|
| 246 | 259 | assert!(r.body.contains("Say how it was resolved"), "{}", r.body); |
|
| 247 | 260 | let r = post( |
|
| 248 | 261 | app, |
|
| … | 2 unchanged lines |
||
| 251 | 264 | &format!("_csrf={csrf}&finding={stripe_id}&reason=revoked+in+the+Stripe+dashboard"), |
|
| 252 | 265 | ) |
|
| 253 | 266 | .await; |
|
| 254 | - | assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
|
| 267 | + | let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await; |
|
| 255 | 268 | assert!(r.body.contains("marked resolved") && r.body.contains("revoked in the Stripe dashboard"), "{}", r.body); |
|
| 256 | 269 | let open = db::push_protection::history(&app.db, row(app).await.id, 50).await.unwrap(); |
|
| 257 | 270 | assert!(open.iter().all(|f| f.id != stripe_id), "{open:?}"); |
|
| 258 | 271 | let resolved = db::push_protection::resolved(&app.db, row(app).await.id, 50).await.unwrap(); |
|
| 259 | 272 | assert!(resolved.iter().any(|f| f.id == stripe_id && f.resolved_by == "andy" && f.resolved_ms.is_some()), "{resolved:?}"); |
|
| 260 | 273 | let r = post(app, "/andy/demo/settings/secret-resolve", Some(&andy), &format!("_csrf={csrf}&finding={stripe_id}&reason=again")).await; |
|
| 274 | + | let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await; |
|
| 261 | 275 | assert!(r.body.contains("not open here"), "only once"); |
|
| 262 | 276 | let audit = db::accounts::audit_rows(&app.db, 50).await.unwrap(); |
|
| 263 | 277 | assert!( |
|
| … | 4 unchanged lines |
||
| 268 | 282 | // Allowed from its history row (a test token): no longer open. |
|
| 269 | 283 | let r = |
|
| 270 | 284 | post(app, "/andy/demo/settings/secret-bypass", Some(&andy), &format!("_csrf={csrf}&fingerprint={gfp}&reason=a+test+fixture")).await; |
|
| 271 | - | assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
|
| 285 | + | let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await; |
|
| 286 | + | assert!(r.body.contains(&format!("Fingerprint {gfp} is allowed here")), "{}", r.body); |
|
| 272 | 287 | assert!(db::push_protection::history(&app.db, row(app).await.id, 50).await.unwrap().is_empty()); |
|
| 273 | 288 | let audit = db::accounts::audit_rows(&app.db, 50).await.unwrap(); |
|
| 274 | 289 | assert!(audit.iter().any(|a| a.action == "secret.bypass" && a.detail.contains(&gfp) && a.detail.contains("github-token")), "{audit:?}"); |
|
| … | 4 unchanged lines |
||
| 279 | 294 | let r = post(app, "/andy/demo/settings/secret-bypass/remove", Some(&bob), &format!("_csrf={bob_csrf}&fingerprint={gfp}")).await; |
|
| 280 | 295 | assert_eq!(r.status, StatusCode::NOT_FOUND, "write is not maintain"); |
|
| 281 | 296 | let r = post(app, "/andy/demo/settings/secret-bypass/remove", Some(&andy), &format!("_csrf={csrf}&fingerprint={gfp}")).await; |
|
| 282 | - | assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
|
| 297 | + | let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await; |
|
| 283 | 298 | assert!(r.body.contains(&format!("Fingerprint {gfp} is no longer allowed")), "{}", r.body); |
|
| 284 | 299 | assert!(db::push_protection::allowed(&app.db, row(app).await.id).await.unwrap().is_empty()); |
|
| 285 | 300 | let audit = db::accounts::audit_rows(&app.db, 50).await.unwrap(); |
|
| … | 4 unchanged lines |
||
| 290 | 305 | let open = db::push_protection::history(&app.db, row(app).await.id, 50).await.unwrap(); |
|
| 291 | 306 | assert!(open.iter().any(|f| f.fingerprint == gfp), "{open:?}"); |
|
| 292 | 307 | let r = post(app, "/andy/demo/settings/secret-bypass/remove", Some(&andy), &format!("_csrf={csrf}&fingerprint={gfp}")).await; |
|
| 308 | + | let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await; |
|
| 293 | 309 | assert!(r.body.contains("is not allowed here"), "{}", r.body); |
|
| 294 | 310 | write(work.path(), "ci2.env", format!("GH_TOKEN={ghp}\n").as_bytes()); |
|
| 295 | 311 | client.snapshot("t").unwrap(); |
|
| … | 7 unchanged lines |
||
| 303 | 319 | // Scan history now: the whole history again, audited; nothing found |
|
| 304 | 320 | // twice, and what was resolved stays resolved. |
|
| 305 | 321 | let r = post(app, "/andy/demo/settings/secret-scan", Some(&andy), &format!("_csrf={csrf}")).await; |
|
| 306 | - | assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
|
| 322 | + | let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await; |
|
| 307 | 323 | assert!(r.body.contains("will be scanned again"), "{}", r.body); |
|
| 308 | 324 | assert!(db::push_protection::scan_of(&app.db, row(app).await.id).await.unwrap().unwrap().requested_ms.is_some()); |
|
| 309 | 325 | let p = pass(app, &row(app).await, budget).await.unwrap(); |
|
| … | 104 unchanged lines |
||
| 414 | 430 | } |
|
| 415 | 431 | tokio::time::sleep(Duration::from_millis(100)).await; |
|
| 416 | 432 | } |
|
| 417 | - | let page = get(app, "/andy/demo/settings", Some(&andy)).await; |
|
| 433 | + | let page = get(app, "/andy/demo/settings/push-protection", Some(&andy)).await; |
|
| 418 | 434 | assert!(page.body.contains("Scanned up to the bookmarks") && page.body.contains("SCAN HISTORY NOW"), "{}", page.body); |
|
| 419 | 435 | } |
modified crates/hub/tests/issues.rs +14 −1
| … | 104 unchanged lines |
||
| 105 | 105 | r.cookie.unwrap() |
|
| 106 | 106 | } |
|
| 107 | 107 | ||
| 108 | + | /// A repository settings form's answer (#278): a redirect to the page that |
|
| 109 | + | /// holds the form, followed. |
|
| 110 | + | async fn settled(app: &Shared, r: Res, cookie: Option<&str>, page: &str) -> Res { |
|
| 111 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 112 | + | let to = r.location.expect("a redirect"); |
|
| 113 | + | assert!(to.starts_with(&format!("{page}?")), "{to} is not {page}"); |
|
| 114 | + | get(app, &to, cookie).await |
|
| 115 | + | } |
|
| 116 | + | ||
| 108 | 117 | fn csrf_of(page: &str) -> String { |
|
| 109 | 118 | let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len(); |
|
| 110 | 119 | page[i..].split('"').next().unwrap().to_string() |
|
| … | 631 unchanged lines |
||
| 742 | 751 | &format!("_csrf={csrf}&description=Retro+games&default_bookmark=main&visibility=public&topics=c%2B%2B"), |
|
| 743 | 752 | ) |
|
| 744 | 753 | .await; |
|
| 754 | + | let r = settled(app, r, Some(&andy), "/andy/gamma/settings/general").await; |
|
| 745 | 755 | assert!(r.body.contains("is not a topic"), "{}", r.body); |
|
| 746 | 756 | let r = post( |
|
| 747 | 757 | app, |
|
| … | 2 unchanged lines |
||
| 750 | 760 | &format!("_csrf={csrf}&description=Retro+games&default_bookmark=main&visibility=public&topics=Retro%2C+6502"), |
|
| 751 | 761 | ) |
|
| 752 | 762 | .await; |
|
| 763 | + | let r = settled(app, r, Some(&andy), "/andy/gamma/settings/general").await; |
|
| 753 | 764 | assert!(r.body.contains("Saved"), "{}", r.body); |
|
| 754 | 765 | assert_eq!(db::repos::get(&app.db, "andy", "gamma").await.unwrap().unwrap().topics, "retro 6502"); |
|
| 755 | 766 | let page = get(app, "/andy/gamma", None).await; |
|
| … | 356 unchanged lines |
||
| 1112 | 1123 | assert!(missing.contains("href=\"/bob\"") && missing.contains("1 repository"), "{missing}"); |
|
| 1113 | 1124 | assert!(get(app, "/admin", Some(&andy)).await.body.contains("/admin/backups")); |
|
| 1114 | 1125 | // A repository chooses its own (BP3): its owners decide. |
|
| 1115 | - | let game = get(app, "/bob/game/settings", Some(&bob)).await; |
|
| 1126 | + | let game = get(app, "/bob/game/settings/backup", Some(&bob)).await; |
|
| 1116 | 1127 | assert!(game.body.contains("bob has no backup set up yet") && game.body.contains(r#"name="backup" value="" checked"#), "{}", game.body); |
|
| 1117 | 1128 | let csrf = csrf_of(&game.body); |
|
| 1118 | 1129 | let saved = post(app, "/bob/game/settings/backup", Some(&bob), &format!("_csrf={csrf}&backup=off")).await; |
|
| 1130 | + | let saved = settled(app, saved, Some(&bob), "/bob/game/settings/backup").await; |
|
| 1119 | 1131 | assert!(saved.body.contains("left out of its owner"), "{}", saved.body); |
|
| 1120 | 1132 | assert_eq!(db::repos::get(&app.db, "bob", "game").await.unwrap().unwrap().backup, "off"); |
|
| 1121 | 1133 | let bad = post(app, "/bob/game/settings/backup", Some(&bob), &format!("_csrf={csrf}&backup=weekly")).await; |
|
| 1134 | + | let bad = settled(app, bad, Some(&bob), "/bob/game/settings/backup").await; |
|
| 1122 | 1135 | assert!(bad.body.contains("Pick how this repository is backed up.")); |
|
| 1123 | 1136 | } |
|
| 1124 | 1137 | ||
| … | 75 unchanged lines |
modified crates/hub/tests/proposals.rs +2 −1
| … | 223 unchanged lines |
||
| 224 | 224 | post(app, "/andy/demo/settings/requirements", Some(&andy), &format!("_csrf={csrf_a}&required_approvals=1&require_owners=1")) |
|
| 225 | 225 | .await |
|
| 226 | 226 | .status, |
|
| 227 | - | StatusCode::OK |
|
| 227 | + | StatusCode::SEE_OTHER, |
|
| 228 | + | "back to Settings › Landing rules" |
|
| 228 | 229 | ); |
|
| 229 | 230 | let page = get(app, "/andy/demo/proposal/1", Some(&andy)).await; |
|
| 230 | 231 | assert!(page.body.contains("0 of 1 required sealed approvals"), "{}", page.body); |
|
| … | 789 unchanged lines |
modified crates/hub/tests/push_protection.rs +23 −8
| … | 63 unchanged lines |
||
| 64 | 64 | status: StatusCode, |
|
| 65 | 65 | body: String, |
|
| 66 | 66 | cookie: Option<String>, |
|
| 67 | + | location: Option<String>, |
|
| 67 | 68 | } |
|
| 68 | 69 | ||
| 69 | 70 | async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, form: Option<&str>) -> Res { |
|
| … | 17 unchanged lines |
||
| 87 | 88 | .and_then(|v| v.split(';').next()) |
|
| 88 | 89 | .and_then(|kv| kv.split_once('=')) |
|
| 89 | 90 | .map(|(_, v)| v.to_string()); |
|
| 91 | + | let location = res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).map(String::from); |
|
| 90 | 92 | let bytes = res.into_body().collect().await.unwrap().to_bytes(); |
|
| 91 | - | Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie } |
|
| 93 | + | Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie, location } |
|
| 92 | 94 | } |
|
| 93 | 95 | ||
| 96 | + | /// A repository settings form's answer (#278): a redirect to the page that |
|
| 97 | + | /// holds the form, followed. |
|
| 98 | + | async fn settled(app: &Shared, r: Res, cookie: Option<&str>, page: &str) -> Res { |
|
| 99 | + | assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body); |
|
| 100 | + | let to = r.location.expect("a redirect"); |
|
| 101 | + | assert!(to.starts_with(&format!("{page}?")), "{to} is not {page}"); |
|
| 102 | + | send(app, "GET", &to, cookie, None).await |
|
| 103 | + | } |
|
| 104 | + | ||
| 94 | 105 | async fn get(app: &Shared, path: &str, cookie: Option<&str>) -> Res { |
|
| 95 | 106 | send(app, "GET", path, cookie, None).await |
|
| 96 | 107 | } |
|
| … | 130 unchanged lines |
||
| 227 | 238 | let h1 = client.commit(&format!("add {file}"), &[]).unwrap(); |
|
| 228 | 239 | let err = client.push("origin", false).err().unwrap_or_else(|| panic!("{file} was accepted")).to_string(); |
|
| 229 | 240 | assert!(err.contains("HTTP 422") && err.contains("push protection refused this push"), "{err}"); |
|
| 230 | - | assert!(err.contains("oxy amend") && err.contains(&format!("{}/andy/demo/settings#push-protection", h.base)), "{err}"); |
|
| 241 | + | assert!(err.contains("oxy amend") && err.contains(&format!("{}/andy/demo/settings/push-protection", h.base)), "{err}"); |
|
| 231 | 242 | assert!(!err.contains("hint:"), "no credential hint for a refusal: {err}"); |
|
| 232 | 243 | for (rule, secret, line) in &expect { |
|
| 233 | 244 | let fp = fingerprint(rule, secret.as_bytes()); |
|
| … | 23 unchanged lines |
||
| 257 | 268 | log.iter().any(|p| !p.ok && p.reference == "main" && p.reason.contains("push protection") && p.reason.contains("README.md:3")), |
|
| 258 | 269 | "{log:?}" |
|
| 259 | 270 | ); |
|
| 260 | - | let settings = get(app, "/andy/demo/settings", Some(&andy)).await; |
|
| 271 | + | let settings = get(app, "/andy/demo/settings/push-protection", Some(&andy)).await; |
|
| 261 | 272 | assert!(settings.body.contains("id=\"push-protection\"") && settings.body.contains(&fp), "{}", settings.body); |
|
| 262 | 273 | assert!(!settings.body.contains(&ghp), "the settings page shows no secret"); |
|
| 263 | 274 | ||
| … | 1 unchanged lines |
||
| 265 | 276 | let bob = register(app, "bob").await; |
|
| 266 | 277 | let csrf = csrf_of(&settings.body); |
|
| 267 | 278 | let r = post(app, "/andy/demo/settings/collaborators", Some(&andy), &format!("_csrf={csrf}&username=bob&role=write")).await; |
|
| 268 | - | assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
|
| 279 | + | let r = settled(app, r, Some(&andy), "/andy/demo/settings/collaborators").await; |
|
| 280 | + | assert!(r.body.contains("bob now has write access."), "{}", r.body); |
|
| 269 | 281 | let bob_page = get(app, "/andy/demo", Some(&bob)).await; |
|
| 270 | 282 | let bob_csrf = csrf_of(&bob_page.body); |
|
| 271 | 283 | let r = post(app, "/andy/demo/settings/secret-bypass", Some(&bob), &format!("_csrf={bob_csrf}&fingerprint={fp}&reason=mine")).await; |
|
| … | 2 unchanged lines |
||
| 274 | 286 | ||
| 275 | 287 | // A maintainer allows it, with a reason, audited; the push then lands. |
|
| 276 | 288 | let r = post(app, "/andy/demo/settings/secret-bypass", Some(&andy), &format!("_csrf={csrf}&fingerprint={fp}&reason=")).await; |
|
| 289 | + | let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await; |
|
| 277 | 290 | assert!(r.body.contains("Say why"), "a reason is needed"); |
|
| 278 | 291 | let r = |
|
| 279 | 292 | post(app, "/andy/demo/settings/secret-bypass", Some(&andy), &format!("_csrf={csrf}&fingerprint={fp}&reason=a+revoked+test+token")) |
|
| 280 | 293 | .await; |
|
| 281 | - | assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
|
| 294 | + | let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await; |
|
| 282 | 295 | assert!(r.body.contains(&format!("Fingerprint {fp} is allowed")) && r.body.contains("a revoked test token"), "{}", r.body); |
|
| 283 | 296 | let audit = db::accounts::audit_rows(&app.db, 20).await.unwrap(); |
|
| 284 | 297 | assert!(audit.iter().any(|a| a.action == "secret.bypass" && a.detail.contains(&fp) && a.detail.contains("github-token")), "{audit:?}"); |
|
| … | 10 unchanged lines |
||
| 295 | 308 | client.commit("pay", &[]).unwrap(); |
|
| 296 | 309 | let err = client.push("origin", false).err().unwrap().to_string(); |
|
| 297 | 310 | assert!(err.contains("an agent's token (Claude)") && err.contains("do not try to get around the refusal"), "{err}"); |
|
| 298 | - | let settings = get(app, "/andy/demo/settings", Some(&andy)).await; |
|
| 311 | + | let settings = get(app, "/andy/demo/settings/push-protection", Some(&andy)).await; |
|
| 299 | 312 | assert!(settings.body.contains("via Claude"), "the finding names the agent"); |
|
| 300 | 313 | ||
| 301 | 314 | // With push protection off, the same push lands; the change is audited. |
|
| 302 | 315 | let csrf = csrf_of(&settings.body); |
|
| 303 | 316 | let r = post(app, "/andy/demo/settings/push-protection", Some(&andy), &format!("_csrf={csrf}")).await; |
|
| 304 | - | assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
|
| 317 | + | let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await; |
|
| 318 | + | assert!(r.body.contains("Push protection is off"), "{}", r.body); |
|
| 305 | 319 | assert!(!db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap().push_protection); |
|
| 306 | 320 | let audit = db::accounts::audit_rows(&app.db, 20).await.unwrap(); |
|
| 307 | 321 | assert!(audit.iter().any(|a| a.action == "repo.push_protection" && a.detail == "off"), "{audit:?}"); |
|
| 308 | 322 | assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 309 | 323 | let r = post(app, "/andy/demo/settings/push-protection", Some(&andy), &format!("_csrf={csrf}&protect=on")).await; |
|
| 310 | - | assert_eq!(r.status, StatusCode::OK, "{}", r.body); |
|
| 324 | + | let r = settled(app, r, Some(&andy), "/andy/demo/settings/push-protection").await; |
|
| 325 | + | assert!(r.body.contains("Push protection is on"), "{}", r.body); |
|
| 311 | 326 | assert!(db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap().push_protection); |
|
| 312 | 327 | } |
|
| 313 | 328 | ||
| … | 32 unchanged lines |
modified crates/hub/tests/releases.rs +3 −0
| … | 270 unchanged lines |
||
| 271 | 271 | assert!(overview.contains("oxy clone ") && !overview.contains("git clone "), "no Git in the Code panel without the mirror"); |
|
| 272 | 272 | let settings = send(app, "GET", "/andy/demo/settings", Some(&andy), None, None, vec![]).await.text(); |
|
| 273 | 273 | let r = form(app, "/andy/demo/settings/mirror", &andy, &format!("_csrf={}&enabled=1", csrf_of(&settings))).await; |
|
| 274 | + | let back = r.location.clone().unwrap_or_default(); |
|
| 275 | + | assert!(back.starts_with("/andy/demo/settings/git-mirror?"), "{back}"); |
|
| 276 | + | let r = send(app, "GET", &back, Some(&andy), None, None, vec![]).await; |
|
| 274 | 277 | assert!(r.text().contains("mirror is on"), "{}", r.text()); |
|
| 275 | 278 | // The Code panel then offers git clone too: oxy first, then git, then the downloads (#215). |
|
| 276 | 279 | let overview = send(app, "GET", "/andy/demo", Some(&andy), None, None, vec![]).await.text(); |
|
| … | 95 unchanged lines |
modified crates/hub/tests/storage.rs +5 −3
| … | 204 unchanged lines |
||
| 205 | 205 | // The pages say where it lives; Settings → Storage checks the bucket. |
|
| 206 | 206 | let page = send(&h.app, "GET", "/andy/cloudy", Some(&me), None).await.1; |
|
| 207 | 207 | assert!(page.contains("class=\"store store-s3\"") && page.contains(url.split('?').next().unwrap()), "{page}"); |
|
| 208 | - | let settings = send(&h.app, "GET", "/andy/cloudy/settings", Some(&me), None).await.1; |
|
| 208 | + | let settings = send(&h.app, "GET", "/andy/cloudy/settings/storage", Some(&me), None).await.1; |
|
| 209 | 209 | assert!(settings.contains("id=\"storage\""), "{settings}"); |
|
| 210 | 210 | let csrf = csrf_of(&settings); |
|
| 211 | - | let (_, after, _) = |
|
| 211 | + | let (st, _, _) = |
|
| 212 | 212 | send(&h.app, "POST", "/andy/cloudy/settings/storage", Some(&me), Some(&format!("_csrf={csrf}&action=check"))).await; |
|
| 213 | - | assert!(after.contains("The bucket passed the check: 8 checks passed."), "{after}"); |
|
| 213 | + | assert_eq!(st, StatusCode::SEE_OTHER, "back to Settings › Storage"); |
|
| 214 | + | let after = send(&h.app, "GET", "/andy/cloudy/settings/storage", Some(&me), None).await.1; |
|
| 215 | + | assert!(after.contains("8 checks passed"), "{after}"); |
|
| 214 | 216 | // A second repository cannot start in the same place. |
|
| 215 | 217 | let (st, body) = new_repo(&h.app, &me, &fields.replace("name=cloudy", "name=again")).await; |
|
| 216 | 218 | assert_eq!(st, StatusCode::OK); |
|
| … | 53 unchanged lines |
modified docs/spec.md +2 −2
| … | 276 unchanged lines |
||
| 277 | 277 | | `/{owner}/{name}/terraform` | Terraform state (§13.5.4): the backend block, how a pipeline and a person authenticate, and the states with their serials, last writer, lock and versions kept | |
|
| 278 | 278 | | `/{owner}/{name}/trust` | Policy, roots, rules, hub delegate status, verification summary (§6.11) | |
|
| 279 | 279 | | `/{owner}/{name}/pushes` | Reference update log: who pushed what, when, with which key, result (§6.12) | |
|
| 280 | - | | `/{owner}/{name}/settings/*` | General, collaborators, bookmarks (default, delete), tokens, webhooks, mirror, danger zone | |
|
| 280 | + | | `/{owner}/{name}/settings/{page}` | A repository's settings (oxygenhub#278), one page per setting, with a sidebar of grouped pages beside it and the current one marked (`aria-current`); on a narrow screen the sidebar is a compact list above the page. **General**: General (`general`; `/settings` shows it), Collaborators (`collaborators`). **Code**: Landing rules (`landing`), Required checks (`checks`), Push protection (`push-protection`). **Flow**: Pipelines (`flow`: OxygenFlow on or off), Runs & priority (`runs`: priority, newest run wins), Gates & agents (`gates`), Runners (`runners`), Variables (`variables`), Secrets (`secrets`), Coverage (`coverage`). **Integrations**: Webhooks (`webhooks`), AI assistants (`assistants`), Git mirror (`git-mirror`), Diffusion (`diffusion`). **Data**: Storage (`storage`, a repository kept in a bucket only), Backup (`backup`). **Danger zone**: Advanced (`advanced`: transfer, archive, delete wiki data, delete). The repository's maintainers have the pages; Collaborators, Runners, Variables, Secrets, Webhooks, AI assistants, Storage and Advanced are its admins' only, left out of the sidebar and not found for others. An archived repository's settings are its admins' only. Each form posts to its own path, as before, and redirects to the page that holds it with a notice or an error; a runner's registration token is shown once on the Runners page. A kept link to the former one-page settings, `/settings#section`, opens the page that has that section (`hub.js`). Names such as *Settings › Push protection* are these pages | |
|
| 281 | 281 | | `/{owner}/{name}/edit/{ref}/{path}`, `/new/{ref}/{path}` | Web edit (§6.9) | |
|
| 282 | 282 | | `/settings/*` | Profile, security (password, TOTP, sessions), keys (linked identities), tokens, notifications, theme | |
|
| 283 | 283 | | `/notifications` | Inbox (§10) | |
|
| … | 438 unchanged lines |
||
| 722 | 722 | - **Rules** are data, `crates/hub/src/push_protection.toml`, compiled in and versioned (`version`, shown in every refusal as `rules vN`). Each has an id, a description, a regex matched on bytes (its `secret` group, else the whole match, is the secret), optional keywords that must occur (case-insensitive) before the regex runs, and optionally a regex the same file or inserted text must also match, for secrets that are only one when paired. The first set: AWS access key IDs, AWS secret access keys next to a key ID, Google Cloud service account keys (`"type": "service_account"` with a `private_key`), Azure storage connection strings, GitHub tokens (`ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_`, `github_pat_`), GitLab personal access tokens (`glpat-`), this hub's own credentials (personal access tokens `oxyhub_`, agent grant tokens `oxyagent_`, OxygenFlow job tokens `flowjob_`, runner credentials `flowrunner_` and registration tokens `flowreg_`), Slack tokens (`xoxb-` and kin), Stripe live keys (`sk_live_`, `rk_live_`), OpenSSH and PEM private keys (a `BEGIN … PRIVATE KEY` line, a body and its `END` line; the header alone, as in documentation, is not one). Rules are tried in order and a match overlapping an earlier rule's is dropped, so one secret is reported once, by its most specific rule. |
|
| 723 | 723 | - **Findings.** Each names its rule, the file and line (the line as the change that adds it leaves the file, worked out from the repository's state with the push's changes applied in memory, only when there is a finding), a fingerprint (the first 16 hex digits of BLAKE3 of the rule id, a NUL and the secret) and the line with the secret masked (its first four characters kept). The secret itself is never shown, logged or stored. |
|
| 724 | 724 | - **Budget**, so pushes stay fast: at most `scan_limit` bytes scanned per push request (16 MiB), each file or inserted text at most `scan_file_limit` (1 MiB; larger ones, and binary blobs, with a NUL in the first 8000 bytes, are skipped), and `scan_time_ms` (2000) in all, all under `[limits]` in `hub.toml`. Past the size or time budget the rest goes unscanned and the push is **accepted**: refusing would block every large import (a whole history pushed in parts, each part with its own budget) for what is most likely nothing. The accepted references' push log entries say how much went unscanned (`push protection scanned 16.0 MiB and stopped at its size budget: … more were not scanned`). History the hub already holds is the history scan's (below). |
|
| 725 | - | - **Refusal.** The push fails with HTTP `422`, nothing of it stored or moved, and a plain text the client prints as it is: one line per finding, `path:line: description (fingerprint F)` with its masked line under it (20 at most, then a count), then what to do: take the value out of the change that adds it (`oxy amend`, or `oxy abandon` a change that only adds it) and push again, revoke the credential if it was real, or have a maintainer allow the fingerprint at `{base_url}/{owner}/{name}/settings#push-protection`. A push through an agent's token (an agent-marked token or a keyless agent's grant) adds a paragraph for the agent naming it: remove the value and push again, do not work around the refusal, and tell its person if it was asked to commit the value, since only a maintainer can allow one. The refusal is recorded in the push log (`/{owner}/{name}/pushes`, each named reference refused with `push protection: N possible secrets (path:line, …)`), and its findings (rule, path, line, fingerprint, masked line, pusher and agent) in `secret_findings` (the newest 200 per repository) for the maintainers. |
|
| 725 | + | - **Refusal.** The push fails with HTTP `422`, nothing of it stored or moved, and a plain text the client prints as it is: one line per finding, `path:line: description (fingerprint F)` with its masked line under it (20 at most, then a count), then what to do: take the value out of the change that adds it (`oxy amend`, or `oxy abandon` a change that only adds it) and push again, revoke the credential if it was real, or have a maintainer allow the fingerprint at `{base_url}/{owner}/{name}/settings/push-protection`. A push through an agent's token (an agent-marked token or a keyless agent's grant) adds a paragraph for the agent naming it: remove the value and push again, do not work around the refusal, and tell its person if it was asked to commit the value, since only a maintainer can allow one. The refusal is recorded in the push log (`/{owner}/{name}/pushes`, each named reference refused with `push protection: N possible secrets (path:line, …)`), and its findings (rule, path, line, fingerprint, masked line, pusher and agent) in `secret_findings` (the newest 200 per repository) for the maintainers. |
|
| 726 | 726 | - **Bypass.** Under Settings › Push protection (maintain role) a maintainer sees the recent findings not yet allowed and allows one by its fingerprint with a reason (required, at most 500 characters), from the finding's row or by typing a fingerprint from a refusal: a row in `secret_bypasses` (repository, fingerprint, the rule it was found under, reason, who, when), audited as `secret.bypass`. A finding whose fingerprint is allowed passes; the allowed fingerprints are listed with their reasons. A writer cannot allow one. Each allowed fingerprint has a **remove** button (maintain role, `POST …/settings/secret-bypass/remove`), audited as `secret.bypass_removed` with the reason it had: pushes adding that value are refused again and its history findings are open again (oxygenhub#277). |
|
| 727 | 727 | - **Setting.** Per repository, `repositories.push_protection`, on by default; Settings › Push protection (maintain role) turns it off or on, audited as `repo.push_protection`. A wiki follows its repository's setting and bypasses (migration 0085, both databases). |
|
| 728 | 728 | - **History scan** (oxygenhub#277). The same rules run over what each repository already holds, so a secret committed before push protection existed, or while it was off, is found too. What is scanned is every change reachable from the repository's bookmarks, read as push protection reads a push: the lines each change inserts and the text blobs it puts in place of a file's content (within `scan_file_limit`; binary and lazy blobs are skipped). It is the worker role's `secrets.history_scan` task (docs/roles.md), under its leader lock: ten seconds after the process starts it passes over every repository (so the first deploy scans every repository's whole history), then over a repository again when a push to it is announced on the bus (`repo:N`, `push`), when a maintainer asks or turns push protection back on, and every 15 minutes over all of them for what the bus missed (a landing, a lost event). Repositories with push protection off are skipped; wikis are not scanned. |
|
| … | 138 unchanged lines |
modified templates/deliveries.html +1 −1
| … | 4 unchanged lines |
||
| 5 | 5 | {% block mainclass %} class="wide"{% endblock %} |
|
| 6 | 6 | {% block content %} |
|
| 7 | 7 | <div class="head"><h1>Deliveries <small><code class="hook-url">{{ crate::db::hooks::masked_url(hook.url) }}</code> · {{ hook.format }} · events {{ hook.events }}</small></h1></div> |
|
| 8 | - | <p><a href="{{ settings_url }}#webhooks">← settings</a></p> |
|
| 8 | + | <p><a href="{{ back_url }}">← settings</a></p> |
|
| 9 | 9 | {% if rows.is_empty() %}<p class="empty">Nothing delivered yet.</p>{% else %} |
|
| 10 | 10 | <div class="table"><table> |
|
| 11 | 11 | <tr><th>When</th><th>Event</th><th>Delivery</th><th>Attempts</th><th>Status</th><th>Response</th><th></th></tr> |
|
| … | 9 unchanged lines |
modified templates/flow_runs.html +1 −1
| … | 13 unchanged lines |
||
| 14 | 14 | {% if enabled %} |
|
| 15 | 15 | <p>No runs yet. Add <code>.flow/pipeline.kdl</code> to a bookmark and push: every push that moves a bookmark runs it in OxygenFlow microVMs. Once it is there, <strong>Run pipeline</strong> above runs a bookmark's current state without a push, as does the button beside each bookmark and on each change.</p> |
|
| 16 | 16 | {% else %} |
|
| 17 | - | <p>OxygenFlow is not enabled for this repository. A site administrator turns it on under <a href="{{ repo.url }}/settings#flow">Settings → OxygenFlow</a>.</p> |
|
| 17 | + | <p>OxygenFlow is not enabled for this repository. A site administrator turns it on under <a href="{{ repo.url }}/settings/flow">Settings › Pipelines</a>.</p> |
|
| 18 | 18 | {% endif %} |
|
| 19 | 19 | <pre class="snippet">schema-version 1 |
|
| 20 | 20 | ||
| … | 32 unchanged lines |
modified templates/proposal.html +1 −1
| … | 188 unchanged lines |
||
| 189 | 189 | {% else if p.state == "closed" %}<p class="mute">closed</p> |
|
| 190 | 190 | {% else %} |
|
| 191 | 191 | {% if blockers.is_empty() %}<p><span class="sig sig-valid">✔</span> ready to land</p> |
|
| 192 | - | {% if requirements.required_approvals == 0 && approvals.is_empty() %}<p class="small warn-text">No approval yet, and this repository's <a href="{{ repo.url }}/settings">settings</a> require none.</p>{% endif %} |
|
| 192 | + | {% if requirements.required_approvals == 0 && approvals.is_empty() %}<p class="small warn-text">No approval yet, and this repository's <a href="{{ repo.url }}/settings/landing">landing rules</a> require none.</p>{% endif %} |
|
| 193 | 193 | {% else %}<ul class="plain small">{% for b in blockers %}<li><span class="sig sig-invalid">✘</span> {{ b }}</li>{% endfor %}</ul>{% endif %} |
|
| 194 | 194 | {% if can_land %} |
|
| 195 | 195 | {% if hub_can_land %}<form method="post" action="{{ repo.url }}/proposal/{{ p.number }}/land"{% if approvals.is_empty() %} data-confirm="Land #{{ p.number }} without any approval?"{% endif %}><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" data-busy="LANDING">LAND</button></form> |
|
| … | 70 unchanged lines |
modified templates/repo_settings.html +34 −25
| … | 3 unchanged lines |
||
| 4 | 4 | {% import "variables_panel.html" as va %} |
|
| 5 | 5 | {% import "secrets_panel.html" as se %} |
|
| 6 | 6 | {% import "repo_tabs.html" as m %} |
|
| 7 | - | {% block title %}Settings · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 7 | + | {% block title %}{% if section != Section::General %}{{ section.title() }} · {% endif %}Settings · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 8 | 8 | {% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %} |
|
| 9 | 9 | {% block tabs %}{% call m::tabs(repo, "settings") %}{% endblock %} |
|
| 10 | 10 | {% block content %} |
|
| 11 | 11 | <div class="head"><h1>Settings <small>{{ repo.owner }}/{{ repo.name }}</small></h1></div> |
|
| 12 | + | <div class="settings-layout"> |
|
| 13 | + | <nav class="settings-nav" aria-label="Settings"{% if section == Section::General %} data-settings-anchors{% endif %}> |
|
| 14 | + | {% for (group, pages) in nav %}<div class="settings-group"> |
|
| 15 | + | <h2>{{ group }}</h2> |
|
| 16 | + | <ul>{% for p in pages %}<li><a href="{{ repo.url }}/settings/{{ p.slug() }}" data-slug="{{ p.slug() }}"{% if *p == section %} class="on" aria-current="page"{% endif %}>{{ p.title() }}</a></li>{% endfor %}</ul> |
|
| 17 | + | </div>{% endfor %} |
|
| 18 | + | </nav> |
|
| 19 | + | <div class="sections settings-body"> |
|
| 12 | 20 | {% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %} |
|
| 13 | 21 | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 14 | - | <div class="sectioned"> |
|
| 15 | - | <nav class="section-nav" aria-label="Sections"> |
|
| 16 | - | <a href="#general">General</a> |
|
| 17 | - | <a href="#landing">Landing requirements</a> |
|
| 18 | - | <a href="#push-protection">Push protection</a> |
|
| 19 | - | <a href="#git-mirror">Git mirror</a> |
|
| 20 | - | <a href="#checks">Required checks</a> |
|
| 21 | - | <a href="#flow">OxygenFlow</a> |
|
| 22 | - | <a href="#coverage">Coverage</a> |
|
| 23 | - | {% if role == crate::auth::Role::Admin %}<a href="#webhooks">Webhooks</a> |
|
| 24 | - | <a href="#variables">Variables</a> |
|
| 25 | - | <a href="#secrets">Secrets</a> |
|
| 26 | - | <a href="#assistants">AI assistants</a> |
|
| 27 | - | {% if storage.is_some() %}<a href="#storage">Storage</a>{% endif %} |
|
| 28 | - | <a href="#collaborators">Collaborators</a> |
|
| 29 | - | <a href="#advanced">Advanced</a>{% endif %} |
|
| 30 | - | </nav> |
|
| 31 | - | <div class="sections"> |
|
| 22 | + | {% match section %} |
|
| 23 | + | {% when Section::General %} |
|
| 32 | 24 | <form class="card wide" id="general" method="post" action="{{ repo.url }}/settings"> |
|
| 33 | 25 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 34 | 26 | <h2>General</h2> |
|
| … | 9 unchanged lines |
||
| 44 | 36 | </div> |
|
| 45 | 37 | <button type="submit">SAVE</button> |
|
| 46 | 38 | </form> |
|
| 39 | + | {% when Section::Landing %} |
|
| 47 | 40 | <form class="card wide" id="landing" method="post" action="{{ repo.url }}/settings/requirements"> |
|
| 48 | 41 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 49 | 42 | <h2>Landing requirements</h2> |
|
| … | 4 unchanged lines |
||
| 54 | 47 | </div> |
|
| 55 | 48 | <button type="submit">SAVE</button> |
|
| 56 | 49 | </form> |
|
| 50 | + | {% when Section::PushProtection %} |
|
| 57 | 51 | <section class="panel" id="push-protection"> |
|
| 58 | 52 | <h2>Push protection</h2> |
|
| 59 | 53 | <p>A push that adds what looks like a credential (cloud keys, tokens, private keys) is refused before any of it is stored, naming the file, line and a fingerprint for each. Only what a push adds is scanned, up to a budget; the push log says when a large push was not scanned in full. What the repository already holds is scanned in the background (Found in history, below).</p> |
|
| … | 55 unchanged lines |
||
| 115 | 109 | {% endfor %} |
|
| 116 | 110 | </table>{% endif %} |
|
| 117 | 111 | </section> |
|
| 112 | + | {% when Section::GitMirror %} |
|
| 118 | 113 | <form class="card wide" id="git-mirror" method="post" action="{{ repo.url }}/settings/mirror"> |
|
| 119 | 114 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 120 | 115 | <h2>Git mirror</h2> |
|
| … | 1 unchanged lines |
||
| 122 | 117 | <p class="small mute">For tools that only speak Git (CI runners, IDEs): <code>git clone {{ shell.base_url }}{{ repo.url }}.git</code>. Private repositories take a personal access token as the password. Pushing to the mirror is refused.{% if row.git_mirror && !row.git_mirror_state.is_empty() %} Last exported state <code>{{ row.git_mirror_state|short }}</code>.{% endif %}</p> |
|
| 123 | 118 | <button type="submit">SAVE</button> |
|
| 124 | 119 | </form> |
|
| 120 | + | {% when Section::Checks %} |
|
| 125 | 121 | <form class="card wide" id="checks" method="post" action="{{ repo.url }}/settings/checks"> |
|
| 126 | 122 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 127 | 123 | <h2>Required checks</h2> |
|
| … | 1 unchanged lines |
||
| 129 | 125 | <p class="small mute">Any token with <code>checks:write</code> reports checks with <code>POST {{ shell.base_url }}/api/v1/repos/{{ repo.owner }}/{{ repo.name }}/checks</code>.</p> |
|
| 130 | 126 | <button type="submit">SAVE</button> |
|
| 131 | 127 | </form> |
|
| 128 | + | {% when Section::Pipelines %} |
|
| 132 | 129 | <form class="card wide" id="flow" method="post" action="{{ repo.url }}/settings/flow"> |
|
| 133 | 130 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 134 | 131 | <h2>OxygenFlow</h2> |
|
| … | 5 unchanged lines |
||
| 140 | 137 | <label><input type="radio" name="flow" value="off"{% if row.flow == "off" %} checked{% endif %}> off</label> |
|
| 141 | 138 | {% if site_admin %}<label><input type="radio" name="flow" value="default"{% if row.flow.is_empty() %} checked{% endif %}> site default ({% if flow_default %}on{% else %}off{% endif %}, from <code>[flow] repos</code> in <code>hub.toml</code>)</label>{% endif %} |
|
| 142 | 139 | </div> |
|
| 143 | - | <p class="small mute"><strong>Own runners only</strong>: jobs go only to runners registered for this repository or its owner (Runners, below), never to the site's. Only site administrators let the site's runners take them. It takes effect at the next push.</p> |
|
| 140 | + | <p class="small mute"><strong>Own runners only</strong>: jobs go only to runners registered for this repository or its owner (Runners), never to the site's. Only site administrators let the site's runners take them. It takes effect at the next push.</p> |
|
| 144 | 141 | <button type="submit">SAVE</button> |
|
| 145 | 142 | {% else %} |
|
| 146 | - | <p class="small mute">To run pipelines on your own machines, register a runner for this repository under <a href="#runners">Runners</a>, then choose <strong>on, own runners only</strong> here. A site administrator can also let the site's runners take its jobs.</p> |
|
| 143 | + | <p class="small mute">To run pipelines on your own machines, register a runner for this repository under {% if role == crate::auth::Role::Admin %}<a href="{{ repo.url }}/settings/runners">Runners</a>{% else %}Runners{% endif %}, then choose <strong>on, own runners only</strong> here. A site administrator can also let the site's runners take its jobs.</p> |
|
| 147 | 144 | {% endif %} |
|
| 148 | 145 | </form> |
|
| 146 | + | {% when Section::Runs %} |
|
| 149 | 147 | <form class="card wide" id="flow-priority" method="post" action="{{ repo.url }}/settings/flow-priority"> |
|
| 150 | 148 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 151 | 149 | <h2>OxygenFlow priority</h2> |
|
| … | 13 unchanged lines |
||
| 165 | 163 | <label class="check"><input type="checkbox" name="running"{% if !row.flow_keep_running %} checked{% endif %}> Cancel superseded runs already running too (untick to let them finish and cancel only those still queued)</label> |
|
| 166 | 164 | <button type="submit">SAVE</button> |
|
| 167 | 165 | </form> |
|
| 166 | + | {% when Section::Gates %} |
|
| 168 | 167 | <form class="card wide" id="agent-gates" method="post" action="{{ repo.url }}/settings/agent-gates"> |
|
| 169 | 168 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 170 | 169 | <h2>Approval gates and agents</h2> |
|
| … | 1 unchanged lines |
||
| 172 | 171 | <label class="check"><input type="checkbox" name="agents"{% if row.agents_decide_gates %} checked{% endif %}> Let agents' tokens approve and reject this repository's approval gates (each decision still says which agent)</label> |
|
| 173 | 172 | <button type="submit">SAVE</button> |
|
| 174 | 173 | </form> |
|
| 174 | + | {% when Section::Diffusion %} |
|
| 175 | 175 | <form class="card wide" id="diffusion" method="post" action="{{ repo.url }}/settings/diffusion"> |
|
| 176 | 176 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 177 | 177 | <h2>Diffusion</h2> |
|
| … | 13 unchanged lines |
||
| 191 | 191 | <p class="small mute">A subrepo's <code>.oxymodules</code> entry overrides this with <code>follow = green</code>, <code>any</code> or <code>off</code>; <code>land = auto</code> or <code>propose</code> overrides the landing, <code>target</code> names the bookmark to move (else the one this repository's proposals land on) and <code>watch</code> lists paths whose upstream changes are flagged.</p> |
|
| 192 | 192 | <button type="submit">SAVE</button> |
|
| 193 | 193 | </form> |
|
| 194 | + | {% when Section::Backup %} |
|
| 194 | 195 | <form class="card wide" id="backup" method="post" action="{{ repo.url }}/settings/backup"> |
|
| 195 | 196 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 196 | 197 | <h2>Backup</h2> |
|
| … | 8 unchanged lines |
||
| 205 | 206 | <p class="small mute">A repository with its own schedule makes its owner's backup run at least that often; each run sends only what changed and keeps every repository, so any run restores everything. Left out, it is in no new run; earlier runs keep it until they are pruned.</p> |
|
| 206 | 207 | <button type="submit">SAVE</button> |
|
| 207 | 208 | </form> |
|
| 208 | - | {% if role == crate::auth::Role::Admin %} |
|
| 209 | + | {% when Section::Coverage %} |
|
| 209 | 210 | <form class="card wide" id="coverage" method="post" action="{{ repo.url }}/settings/coverage"> |
|
| 210 | 211 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 211 | 212 | <h2>Coverage</h2> |
|
| … | 4 unchanged lines |
||
| 216 | 217 | </div> |
|
| 217 | 218 | <button type="submit">SAVE</button> |
|
| 218 | 219 | </form> |
|
| 220 | + | {% when Section::Runners %} |
|
| 219 | 221 | <section class="panel" id="runners"> |
|
| 220 | 222 | <h2>Runners</h2> |
|
| 221 | 223 | <p class="small">The OxygenFlow runners that may take this repository's jobs: the site's, {{ row.owner }}'s, and this repository's own.</p> |
|
| … | 6 unchanged lines |
||
| 228 | 230 | </table></div>{% endif %} |
|
| 229 | 231 | <form method="post" action="{{ repo.url }}/settings/runners/token"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit">Add a runner for this repository</button></form> |
|
| 230 | 232 | </section> |
|
| 233 | + | {% when Section::Webhooks %} |
|
| 231 | 234 | {% call w::panel(repo.url ~ "/settings", hooks, shell.csrf, "Each delivery is signed with the secret (X-Oxyhub-Signature) and retried with backoff for a day.") %} |
|
| 235 | + | {% when Section::Variables %} |
|
| 232 | 236 | {% call va::panel(repo.url ~ "/settings", variables, shell.csrf, "This repository's jobs get these, beside the ones the site and its owner set.") %} |
|
| 237 | + | {% when Section::Secrets %} |
|
| 233 | 238 | {% call se::panel(repo.url ~ "/settings", secrets, shell.csrf, "This repository's jobs may use these, beside the ones the site and its owner set.") %} |
|
| 239 | + | {% when Section::Assistants %} |
|
| 234 | 240 | {% call ai::panel(repo.url ~ "/settings", assistants, shell.csrf, "This repository may ask these, beside the ones the site and its owner provide.") %} |
|
| 235 | - | {% if role == crate::auth::Role::Admin %}{% if let Some(st) = storage %}<section class="panel" id="storage"> |
|
| 241 | + | {% when Section::Storage %} |
|
| 242 | + | {% if let Some(st) = storage %}<section class="panel" id="storage"> |
|
| 236 | 243 | <h2>Storage</h2> |
|
| 237 | 244 | <p>This repository's data lives in <code>{{ st.url }}</code>, your bucket. This server keeps a cache and the credentials, sealed.</p> |
|
| 238 | 245 | <dl class="facts"> |
|
| … | 22 unchanged lines |
||
| 261 | 268 | <button type="submit">SAVE</button> |
|
| 262 | 269 | </form> |
|
| 263 | 270 | </details> |
|
| 264 | - | </section>{% endif %}{% endif %} |
|
| 271 | + | </section>{% endif %} |
|
| 272 | + | {% when Section::Collaborators %} |
|
| 265 | 273 | <section class="panel" id="collaborators"> |
|
| 266 | 274 | <h2>Collaborators</h2> |
|
| 267 | 275 | {% if collaborators.is_empty() %}<p class="mute small">Nobody beyond the owner{% if !teams.is_empty() %} and the organisation's members{% endif %}.</p>{% else %} |
|
| … | 13 unchanged lines |
||
| 281 | 289 | <button type="submit">ADD</button> |
|
| 282 | 290 | </form> |
|
| 283 | 291 | </section> |
|
| 292 | + | {% when Section::Advanced %} |
|
| 284 | 293 | <section class="panel danger-zone" id="advanced"> |
|
| 285 | 294 | <h2>Advanced</h2> |
|
| 286 | 295 | <p class="small mute">Settings that change what this repository is. Each asks you to confirm; none can be undone from here except archiving.</p> |
|
| … | 40 unchanged lines |
||
| 327 | 336 | </details> |
|
| 328 | 337 | </div> |
|
| 329 | 338 | </section> |
|
| 330 | - | {% endif %} |
|
| 339 | + | {% endmatch %} |
|
| 331 | 340 | </div> |
|
| 332 | 341 | </div> |
|
| 333 | 342 | {% endblock %} |
modified templates/repo_tabs.html +1 −1
| 1 | 1 | {% macro tabs(repo, tab) %} |
|
| 2 | 2 | <nav class="tabs" aria-label="Repository"> |
|
| 3 | 3 | <div class="tabs-in"> |
|
| 4 | - | <span class="crumb"><a href="/{{ repo.owner }}">{{ repo.owner }}</a> / <a href="{{ repo.url }}"><strong>{{ repo.name }}</strong></a>{% if repo.archived %} <span class="tag warn" title="This repository is archived: read-only">archived</span>{% endif %} <span class="vis vis-{{ repo.visibility }}" title="{% if repo.visibility == "public" %}Anyone can see this repository{% else if repo.visibility == "internal" %}Anyone signed in to this hub can see this repository{% else %}Only people given access can see this repository{% endif %}"><span aria-hidden="true">{% if repo.visibility == "public" %}◯{% else if repo.visibility == "internal" %}◐{% else %}●{% endif %}</span> {{ repo.visibility }}</span>{% if let Some(b) = repo.stored_in %} <span class="store store-{{ b.kind }}" tabindex="0" aria-label="Kept in its owner's {{ b.provider }}"><svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false">{% if b.kind == "gcs" %}<path d="M4.2 1.5h7.6L15.6 8l-3.8 6.5H4.2L.4 8z" fill="#4285f4"/><rect x="4.6" y="5" width="6.8" height="2" rx=".6" fill="#fff"/><rect x="4.6" y="9" width="6.8" height="2" rx=".6" fill="#fff"/>{% else if b.kind == "s3" %}<path d="M1.8 3.6 3.6 13.2c.2 1 2.2 1.6 4.4 1.6s4.2-.6 4.4-1.6l1.8-9.6z" fill="#7aa116"/><ellipse cx="8" cy="3.6" rx="6.2" ry="2.1" fill="#a4cc4c"/>{% else %}<rect x="1.5" y="2" width="13" height="12" rx="1.6" fill="#0078d4"/><rect x="4" y="5" width="8" height="1.6" fill="#fff"/><rect x="4" y="7.7" width="8" height="1.6" fill="#fff"/><rect x="4" y="10.4" width="5" height="1.6" fill="#fff"/>{% endif %}</svg><span class="store-card" role="tooltip"><strong>{{ b.provider }}</strong>{% if repo.can(crate::auth::Access::Admin) %}<dl><dt>{% if b.kind == "azure" %}Account / container{% else %}Bucket{% endif %}</dt><dd><code>{{ b.place }}</code></dd>{% if !b.prefix.is_empty() %}<dt>Prefix</dt><dd><code>{{ b.prefix }}</code></dd>{% endif %}{% if let Some(r) = b.region %}<dt>Region</dt><dd>{{ r }}</dd>{% endif %}<dt>URL</dt><dd><code>{{ b.url }}</code></dd></dl><a href="{{ repo.url }}/settings#storage">Storage settings and the last check</a>{% else %}<span class="mute">This repository's data is kept in its owner's own {{ b.provider }}, not on this server's disk.</span>{% endif %}</span></span>{% endif %}</span> |
|
| 4 | + | <span class="crumb"><a href="/{{ repo.owner }}">{{ repo.owner }}</a> / <a href="{{ repo.url }}"><strong>{{ repo.name }}</strong></a>{% if repo.archived %} <span class="tag warn" title="This repository is archived: read-only">archived</span>{% endif %} <span class="vis vis-{{ repo.visibility }}" title="{% if repo.visibility == "public" %}Anyone can see this repository{% else if repo.visibility == "internal" %}Anyone signed in to this hub can see this repository{% else %}Only people given access can see this repository{% endif %}"><span aria-hidden="true">{% if repo.visibility == "public" %}◯{% else if repo.visibility == "internal" %}◐{% else %}●{% endif %}</span> {{ repo.visibility }}</span>{% if let Some(b) = repo.stored_in %} <span class="store store-{{ b.kind }}" tabindex="0" aria-label="Kept in its owner's {{ b.provider }}"><svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" focusable="false">{% if b.kind == "gcs" %}<path d="M4.2 1.5h7.6L15.6 8l-3.8 6.5H4.2L.4 8z" fill="#4285f4"/><rect x="4.6" y="5" width="6.8" height="2" rx=".6" fill="#fff"/><rect x="4.6" y="9" width="6.8" height="2" rx=".6" fill="#fff"/>{% else if b.kind == "s3" %}<path d="M1.8 3.6 3.6 13.2c.2 1 2.2 1.6 4.4 1.6s4.2-.6 4.4-1.6l1.8-9.6z" fill="#7aa116"/><ellipse cx="8" cy="3.6" rx="6.2" ry="2.1" fill="#a4cc4c"/>{% else %}<rect x="1.5" y="2" width="13" height="12" rx="1.6" fill="#0078d4"/><rect x="4" y="5" width="8" height="1.6" fill="#fff"/><rect x="4" y="7.7" width="8" height="1.6" fill="#fff"/><rect x="4" y="10.4" width="5" height="1.6" fill="#fff"/>{% endif %}</svg><span class="store-card" role="tooltip"><strong>{{ b.provider }}</strong>{% if repo.can(crate::auth::Access::Admin) %}<dl><dt>{% if b.kind == "azure" %}Account / container{% else %}Bucket{% endif %}</dt><dd><code>{{ b.place }}</code></dd>{% if !b.prefix.is_empty() %}<dt>Prefix</dt><dd><code>{{ b.prefix }}</code></dd>{% endif %}{% if let Some(r) = b.region %}<dt>Region</dt><dd>{{ r }}</dd>{% endif %}<dt>URL</dt><dd><code>{{ b.url }}</code></dd></dl><a href="{{ repo.url }}/settings/storage">Storage settings and the last check</a>{% else %}<span class="mute">This repository's data is kept in its owner's own {{ b.provider }}, not on this server's disk.</span>{% endif %}</span></span>{% endif %}</span> |
|
| 5 | 5 | <a href="{{ repo.url }}"{% if tab == "code" %} class="on" aria-current="page"{% endif %}>Code</a> |
|
| 6 | 6 | <a href="{{ repo.url }}/changes/{{ repo.default_bookmark }}"{% if tab == "changes" %} class="on" aria-current="page"{% endif %}>Changes</a> |
|
| 7 | 7 | <a href="{{ repo.url }}/proposals"{% if tab == "proposals" %} class="on" aria-current="page"{% endif %}>Proposals{% if repo.open_proposals > 0 %} <span class="count-chip">{{ repo.open_proposals }}</span>{% endif %}</a> |
|
| … | 86 unchanged lines |
modified templates/style.css +17 −0
| … | 436 unchanged lines |
||
| 437 | 437 | .sections > form.card, .sections > form.card.wide, .sections > .panel, .sections > details { max-width: none; width: 100%; box-sizing: border-box; scroll-margin-top: 14px; } |
|
| 438 | 438 | .sections .panel form.card { max-width: 720px; } |
|
| 439 | 439 | .sections > details > form.card { max-width: none; } |
|
| 440 | + | /* A repository's settings (#278): grouped pages in a sidebar, one page beside it. */ |
|
| 441 | + | .settings-layout { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 28px; align-items: start; } |
|
| 442 | + | .settings-nav { position: sticky; top: 14px; display: flex; flex-direction: column; gap: 14px; } |
|
| 443 | + | .settings-group h2 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: var(--mute); margin: 0 0 4px; padding: 0 12px; } |
|
| 444 | + | .settings-group ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 1px; } |
|
| 445 | + | .settings-group a { display: block; padding: 5px 12px; border-left: 2px solid var(--line); color: var(--mute); font-size: 14px; } |
|
| 446 | + | .settings-group a:hover { border-left-color: var(--dim); color: var(--ink); text-decoration: none; } |
|
| 447 | + | .settings-group a.on { border-left-color: var(--acc); color: var(--ink); background: var(--soft); } |
|
| 448 | + | @media (max-width: 720px) { |
|
| 449 | + | .settings-layout { grid-template-columns: minmax(0, 1fr); gap: 14px; } |
|
| 450 | + | .settings-nav { position: static; gap: 8px; padding-bottom: 10px; border-bottom: 1px solid var(--line); } |
|
| 451 | + | .settings-group { display: grid; grid-template-columns: 96px minmax(0, 1fr); align-items: baseline; gap: 10px; } |
|
| 452 | + | .settings-group h2 { padding: 0; margin: 0; } |
|
| 453 | + | .settings-group ul { flex-direction: row; flex-wrap: wrap; gap: 4px; min-width: 0; } |
|
| 454 | + | .settings-group a { border-left: 0; border: 1px solid var(--line); padding: 3px 8px; font-size: 13px; } |
|
| 455 | + | .settings-group a.on { border-color: var(--acc); } |
|
| 456 | + | } |
|
| 440 | 457 | @media (max-width: 760px) { |
|
| 441 | 458 | .sectioned { grid-template-columns: 1fr; gap: 12px; } |
|
| 442 | 459 | .section-nav { position: static; flex-direction: row; flex-wrap: wrap; } |
|
| … | 923 unchanged lines |