Organisation webhooks under the organisation's settings, sharing the repository webhook code; an admin storage page with sizes, loose objects and packs, and maintenance (oxy gc) per repository or for all 8fb35df7d66e
closes #13
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-28 13:59 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
8fb35df7d66e37092d69de0770eb9aa983db52b4d43c5826641c8717bd7b258b- Depends on
- 19bb31ff1f5c 43e8ca33bc5d 64caed18236a 93be9ce4653e a160b98fb922 b64e2537d702 d23464c0ac60
- Depended on by
- dc5c64296b0a 737de0fac722 166f6e9bf4f1 c7067c7bda56 33d98147511b 7c06f5b28ade 16ee5d19d220 8dcaeeb848c6 a7aa76ac52d7 66fd9d6b1452 2a83f5303458 e71846aa07b9 6a1780558353 7974adb0be66 ff6569530173 462f88d161ce b6b7e4cf6b24 9756c67d3aec
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 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
- 34 atoms, 3 tree operations, 13 files
Browse the tree at this change
added crates/hub/src/web/webhooks.rs +143 −0
| 1 | + | //! Webhooks of a repository or an organisation (hub spec §12.2): the |
|
| 2 | + | //! settings form, deliveries and redelivery, shared by both scopes. |
|
| 3 | + | ||
| 4 | + | use super::{filters, Error, Page, RepoShell, Shell}; |
|
| 5 | + | use crate::auth::Viewer; |
|
| 6 | + | use crate::db::{self, accounts}; |
|
| 7 | + | use crate::Shared; |
|
| 8 | + | use askama::Template; |
|
| 9 | + | use serde::Deserialize; |
|
| 10 | + | ||
| 11 | + | /// Whose webhooks. |
|
| 12 | + | pub enum Scope { |
|
| 13 | + | Repo { id: i64, slug: String }, |
|
| 14 | + | Org { id: i64, name: String }, |
|
| 15 | + | } |
|
| 16 | + | ||
| 17 | + | impl Scope { |
|
| 18 | + | fn owns(&self, h: &db::hooks::Hook) -> bool { |
|
| 19 | + | match self { |
|
| 20 | + | Scope::Repo { id, .. } => h.repo_id == Some(*id), |
|
| 21 | + | Scope::Org { id, .. } => h.org_id == Some(*id), |
|
| 22 | + | } |
|
| 23 | + | } |
|
| 24 | + | ||
| 25 | + | /// Where the settings page is. |
|
| 26 | + | pub fn settings_url(&self) -> String { |
|
| 27 | + | match self { |
|
| 28 | + | Scope::Repo { slug, .. } => format!("/{slug}/settings"), |
|
| 29 | + | Scope::Org { name, .. } => format!("/orgs/{name}/settings"), |
|
| 30 | + | } |
|
| 31 | + | } |
|
| 32 | + | ||
| 33 | + | fn label(&self) -> &str { |
|
| 34 | + | match self { |
|
| 35 | + | Scope::Repo { slug, .. } => slug, |
|
| 36 | + | Scope::Org { name, .. } => name, |
|
| 37 | + | } |
|
| 38 | + | } |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | #[derive(Deserialize)] |
|
| 42 | + | pub struct HookForm { |
|
| 43 | + | #[serde(rename = "_csrf")] |
|
| 44 | + | pub csrf_token: String, |
|
| 45 | + | #[serde(default)] |
|
| 46 | + | url: String, |
|
| 47 | + | #[serde(default)] |
|
| 48 | + | secret: String, |
|
| 49 | + | #[serde(default = "star")] |
|
| 50 | + | events: String, |
|
| 51 | + | #[serde(default)] |
|
| 52 | + | id: Option<i64>, |
|
| 53 | + | #[serde(default)] |
|
| 54 | + | action: String, |
|
| 55 | + | } |
|
| 56 | + | ||
| 57 | + | fn star() -> String { |
|
| 58 | + | "*".into() |
|
| 59 | + | } |
|
| 60 | + | ||
| 61 | + | /// Apply the webhook form: `Ok(notice)` or `Err(message for the user)`. |
|
| 62 | + | pub async fn apply(app: &Shared, viewer: &Viewer, scope: &Scope, f: HookForm) -> sqlx::Result<Result<String, String>> { |
|
| 63 | + | let owned = |h: Option<db::hooks::Hook>| h.filter(|h| scope.owns(h)); |
|
| 64 | + | Ok(Ok(match (f.action.as_str(), f.id) { |
|
| 65 | + | ("delete", Some(id)) => { |
|
| 66 | + | if owned(db::hooks::hook(&app.db, id).await?).is_some() { |
|
| 67 | + | db::hooks::delete_hook(&app.db, id).await?; |
|
| 68 | + | accounts::audit(&app.db, viewer.account(), "hook.deleted", scope.label(), "").await.ok(); |
|
| 69 | + | } |
|
| 70 | + | "Webhook deleted.".to_string() |
|
| 71 | + | } |
|
| 72 | + | ("toggle", Some(id)) => { |
|
| 73 | + | if let Some(h) = owned(db::hooks::hook(&app.db, id).await?) { |
|
| 74 | + | db::hooks::set_hook_active(&app.db, id, !h.active).await?; |
|
| 75 | + | } |
|
| 76 | + | "Webhook updated.".to_string() |
|
| 77 | + | } |
|
| 78 | + | ("ping", Some(id)) => { |
|
| 79 | + | if let Some(h) = owned(db::hooks::hook(&app.db, id).await?) { |
|
| 80 | + | let delivery = crate::auth::random_hex(16); |
|
| 81 | + | let env = hub_api::WebhookEnvelope { |
|
| 82 | + | event: "ping".into(), |
|
| 83 | + | delivery: delivery.clone(), |
|
| 84 | + | repository: None, |
|
| 85 | + | actor: viewer.account().map(|a| a.username.clone()).unwrap_or_default(), |
|
| 86 | + | at_ms: db::now_ms(), |
|
| 87 | + | data: serde_json::json!({ "zen": "changes commute" }), |
|
| 88 | + | }; |
|
| 89 | + | db::hooks::queue_delivery(&app.db, h.id, &delivery, "ping", &serde_json::to_string(&env).unwrap_or_default()).await?; |
|
| 90 | + | app.events.notify_deliveries(); |
|
| 91 | + | } |
|
| 92 | + | "Ping queued.".to_string() |
|
| 93 | + | } |
|
| 94 | + | _ => { |
|
| 95 | + | if !(f.url.starts_with("http://") || f.url.starts_with("https://")) { |
|
| 96 | + | return Ok(Err("A webhook URL starts with http:// or https://.".into())); |
|
| 97 | + | } |
|
| 98 | + | let (repo_id, org_id) = match scope { |
|
| 99 | + | Scope::Repo { id, .. } => (Some(*id), None), |
|
| 100 | + | Scope::Org { id, .. } => (None, Some(*id)), |
|
| 101 | + | }; |
|
| 102 | + | db::hooks::create_hook(&app.db, repo_id, org_id, f.url.trim(), f.secret.trim(), f.events.trim()).await?; |
|
| 103 | + | accounts::audit(&app.db, viewer.account(), "hook.created", scope.label(), f.url.trim()).await.ok(); |
|
| 104 | + | "Webhook added.".to_string() |
|
| 105 | + | } |
|
| 106 | + | })) |
|
| 107 | + | } |
|
| 108 | + | ||
| 109 | + | #[derive(Template)] |
|
| 110 | + | #[template(path = "deliveries.html")] |
|
| 111 | + | pub struct DeliveriesPage { |
|
| 112 | + | shell: Shell, |
|
| 113 | + | /// The repository's tabs, for a repository's webhook. |
|
| 114 | + | repo: Option<RepoShell>, |
|
| 115 | + | hook: db::hooks::Hook, |
|
| 116 | + | rows: Vec<db::hooks::Delivery>, |
|
| 117 | + | settings_url: String, |
|
| 118 | + | } |
|
| 119 | + | ||
| 120 | + | pub async fn deliveries_page( |
|
| 121 | + | app: &Shared, |
|
| 122 | + | shell: Shell, |
|
| 123 | + | scope: &Scope, |
|
| 124 | + | repo: Option<RepoShell>, |
|
| 125 | + | id: i64, |
|
| 126 | + | ) -> Result<Page<DeliveriesPage>, Error> { |
|
| 127 | + | let hook = db::hooks::hook(&app.db, id) |
|
| 128 | + | .await |
|
| 129 | + | .map_err(|e| Error::from_db(shell.clone(), e))? |
|
| 130 | + | .filter(|h| scope.owns(h)) |
|
| 131 | + | .ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 132 | + | let rows = db::hooks::deliveries_for_hook(&app.db, id, 100).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 133 | + | Ok(Page(DeliveriesPage { shell, repo, hook, rows, settings_url: scope.settings_url() })) |
|
| 134 | + | } |
|
| 135 | + | ||
| 136 | + | pub async fn redeliver(app: &Shared, scope: &Scope, id: i64, delivery: i64) -> sqlx::Result<()> { |
|
| 137 | + | let mine = db::hooks::hook(&app.db, id).await?.is_some_and(|h| scope.owns(&h)); |
|
| 138 | + | if mine && db::hooks::delivery(&app.db, delivery).await?.is_some_and(|d| d.hook_id == id) { |
|
| 139 | + | db::hooks::redeliver(&app.db, delivery).await?; |
|
| 140 | + | app.events.notify_deliveries(); |
|
| 141 | + | } |
|
| 142 | + | Ok(()) |
|
| 143 | + | } |
added templates/admin_storage.html +19 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% block title %}Storage · Administration{% endblock %} |
|
| 3 | + | {% block content %} |
|
| 4 | + | <div class="head"><h1>Storage <small>{{ rows.len() }} repositories · {{ total|bytes }}</small></h1></div> |
|
| 5 | + | <p><a href="/admin">← administration</a></p> |
|
| 6 | + | {% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %} |
|
| 7 | + | {% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %} |
|
| 8 | + | <p class="small mute">Maintenance runs <code>oxy gc</code> on the hub's copy: loose objects go into a pack, and unreachable objects older than the grace period are pruned. Each repository is locked for writing while it runs.</p> |
|
| 9 | + | <form class="tiny" method="post" action="/admin/storage"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="repo" value="*"><button type="submit">MAINTAIN EVERY REPOSITORY</button></form> |
|
| 10 | + | <table class="list"> |
|
| 11 | + | <thead><tr><th>Repository</th><th>On disk</th><th>Loose objects</th><th>Packs</th><th></th></tr></thead> |
|
| 12 | + | <tbody> |
|
| 13 | + | {% for r in rows %} |
|
| 14 | + | <tr><td><a href="/{{ r.slug }}">{{ r.slug }}</a></td><td>{{ r.bytes|bytes }}</td><td>{{ r.loose }}{% if r.loose > 1000 %} <span class="tag warn">pack me</span>{% endif %}</td><td>{{ r.packs }}</td> |
|
| 15 | + | <td><form class="tiny" method="post" action="/admin/storage"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="repo" value="{{ r.slug }}"><button type="submit">maintain</button></form></td></tr> |
|
| 16 | + | {% endfor %} |
|
| 17 | + | </tbody> |
|
| 18 | + | </table> |
|
| 19 | + | {% endblock %} |
added templates/webhooks_panel.html +19 −0
| 1 | + | {% macro panel(base, hooks, csrf, what) %} |
|
| 2 | + | <section class="panel" id="webhooks"> |
|
| 3 | + | <h2>Webhooks</h2> |
|
| 4 | + | <p class="small mute">{{ what }}</p> |
|
| 5 | + | {% if hooks.is_empty() %}<p class="mute small">None.</p>{% else %} |
|
| 6 | + | <table class="small"><tr><th>URL</th><th>Events</th><th>Active</th><th></th></tr> |
|
| 7 | + | {% for h in hooks %}<tr><td>{{ h.url }}</td><td>{{ h.events }}</td><td>{% if h.active %}yes{% else %}no{% endif %}</td> |
|
| 8 | + | <td><a class="small" href="{{ base }}/hooks/{{ h.id }}/deliveries">deliveries</a> |
|
| 9 | + | <form class="tiny" method="post" action="{{ base }}/hooks"><input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="id" value="{{ h.id }}"><button name="action" value="ping">ping</button> <button name="action" value="toggle">{% if h.active %}disable{% else %}enable{% endif %}</button> <button name="action" value="delete">delete</button></form></td></tr>{% endfor %} |
|
| 10 | + | </table>{% endif %} |
|
| 11 | + | <form class="card" method="post" action="{{ base }}/hooks"> |
|
| 12 | + | <input type="hidden" name="_csrf" value="{{ csrf }}"> |
|
| 13 | + | <label for="hurl">URL</label><input type="text" id="hurl" name="url" placeholder="https://…" required> |
|
| 14 | + | <label for="hsecret">Secret (signs each delivery)</label><input type="text" id="hsecret" name="secret"> |
|
| 15 | + | <label for="hevents">Events (<code>*</code>, or a list such as <code>push,proposal,issue</code>)</label><input type="text" id="hevents" name="events" value="*"> |
|
| 16 | + | <button type="submit">ADD WEBHOOK</button> |
|
| 17 | + | </form> |
|
| 18 | + | </section> |
|
| 19 | + | {% endmacro %} |
modified crates/hub/src/web/admin.rs +119 −0
| … | 116 unchanged lines |
||
| 117 | 117 | accounts::audit(&app.db, Some(&me), &format!("admin.account.{}", f.action), &target.username, "").await.ok(); |
|
| 118 | 118 | admin_page(&app, shell, Some(format!("{}: {}.", target.username, f.action))).await |
|
| 119 | 119 | } |
|
| 120 | + | ||
| 121 | + | // ------------------------------------------------------------------ storage |
|
| 122 | + | ||
| 123 | + | /// One repository's footprint on disk. |
|
| 124 | + | pub struct StorageRow { |
|
| 125 | + | pub slug: String, |
|
| 126 | + | pub bytes: u64, |
|
| 127 | + | /// Objects not yet in a pack. |
|
| 128 | + | pub loose: usize, |
|
| 129 | + | pub packs: usize, |
|
| 130 | + | } |
|
| 131 | + | ||
| 132 | + | #[derive(Template)] |
|
| 133 | + | #[template(path = "admin_storage.html")] |
|
| 134 | + | pub struct StoragePage { |
|
| 135 | + | shell: Shell, |
|
| 136 | + | rows: Vec<StorageRow>, |
|
| 137 | + | total: u64, |
|
| 138 | + | notice: Option<String>, |
|
| 139 | + | error: Option<String>, |
|
| 140 | + | } |
|
| 141 | + | ||
| 142 | + | /// Size, loose objects and packs under a repository directory. |
|
| 143 | + | fn footprint(dir: &std::path::Path) -> (u64, usize, usize) { |
|
| 144 | + | let (mut bytes, mut loose, mut packs) = (0u64, 0usize, 0usize); |
|
| 145 | + | let mut stack = vec![dir.to_path_buf()]; |
|
| 146 | + | while let Some(d) = stack.pop() { |
|
| 147 | + | let Ok(rd) = std::fs::read_dir(&d) else { continue }; |
|
| 148 | + | for e in rd.flatten() { |
|
| 149 | + | let Ok(ft) = e.file_type() else { continue }; |
|
| 150 | + | if ft.is_dir() { |
|
| 151 | + | stack.push(e.path()); |
|
| 152 | + | } else if ft.is_file() { |
|
| 153 | + | bytes += e.metadata().map(|m| m.len()).unwrap_or(0); |
|
| 154 | + | let p = e.path(); |
|
| 155 | + | if p.components().any(|c| c.as_os_str() == "loose") { |
|
| 156 | + | loose += 1; |
|
| 157 | + | } else if p.extension().is_some_and(|x| x == "pack") { |
|
| 158 | + | packs += 1; |
|
| 159 | + | } |
|
| 160 | + | } |
|
| 161 | + | } |
|
| 162 | + | } |
|
| 163 | + | (bytes, loose, packs) |
|
| 164 | + | } |
|
| 165 | + | ||
| 166 | + | async fn storage_page(app: &Shared, shell: Shell, notice: Option<String>, error: Option<String>) -> Result<Page<StoragePage>, Error> { |
|
| 167 | + | let repos = crate::db::repos::list(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 168 | + | let dirs: Vec<(String, std::path::PathBuf)> = |
|
| 169 | + | repos.iter().filter_map(|r| crate::repos::RepoName::new(&r.owner, &r.name).map(|n| (n.to_string(), app.repos.dir(&n)))).collect(); |
|
| 170 | + | let mut rows = tokio::task::spawn_blocking(move || { |
|
| 171 | + | dirs.into_iter() |
|
| 172 | + | .map(|(slug, dir)| { |
|
| 173 | + | let (bytes, loose, packs) = footprint(&dir); |
|
| 174 | + | StorageRow { slug, bytes, loose, packs } |
|
| 175 | + | }) |
|
| 176 | + | .collect::<Vec<_>>() |
|
| 177 | + | }) |
|
| 178 | + | .await |
|
| 179 | + | .unwrap_or_default(); |
|
| 180 | + | rows.sort_by_key(|r| std::cmp::Reverse(r.bytes)); |
|
| 181 | + | let total = rows.iter().map(|r| r.bytes).sum(); |
|
| 182 | + | Ok(Page(StoragePage { shell, rows, total, notice, error })) |
|
| 183 | + | } |
|
| 184 | + | ||
| 185 | + | pub async fn storage(State(app): State<Shared>, viewer: Viewer) -> Result<Page<StoragePage>, Error> { |
|
| 186 | + | let shell = Shell::new(&app, "/admin/storage", &viewer); |
|
| 187 | + | require_admin(&viewer, &shell)?; |
|
| 188 | + | storage_page(&app, shell, None, None).await |
|
| 189 | + | } |
|
| 190 | + | ||
| 191 | + | #[derive(Deserialize)] |
|
| 192 | + | pub struct MaintainForm { |
|
| 193 | + | #[serde(rename = "_csrf")] |
|
| 194 | + | csrf_token: String, |
|
| 195 | + | /// `owner/name`, or `*` for every repository. |
|
| 196 | + | repo: String, |
|
| 197 | + | } |
|
| 198 | + | ||
| 199 | + | /// `POST /admin/storage`: `oxy gc` on one repository or all of them, under |
|
| 200 | + | /// each repository's write lock, with the grace period kept. |
|
| 201 | + | pub async fn maintain(State(app): State<Shared>, viewer: Viewer, Form(f): Form<MaintainForm>) -> Result<Page<StoragePage>, Error> { |
|
| 202 | + | let shell = Shell::new(&app, "/admin/storage", &viewer); |
|
| 203 | + | let me = require_admin(&viewer, &shell)?; |
|
| 204 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 205 | + | let names: Vec<crate::repos::RepoName> = if f.repo == "*" { |
|
| 206 | + | crate::db::repos::list(&app.db) |
|
| 207 | + | .await |
|
| 208 | + | .map_err(|e| Error::from_db(shell.clone(), e))? |
|
| 209 | + | .iter() |
|
| 210 | + | .filter_map(|r| crate::repos::RepoName::new(&r.owner, &r.name)) |
|
| 211 | + | .collect() |
|
| 212 | + | } else { |
|
| 213 | + | match crate::repos::RepoName::parse(&f.repo) { |
|
| 214 | + | Some(n) if app.repos.exists(&n) => vec![n], |
|
| 215 | + | _ => return storage_page(&app, shell, None, Some(format!("No repository {}.", f.repo))).await, |
|
| 216 | + | } |
|
| 217 | + | }; |
|
| 218 | + | let (mut packed, mut pruned, mut freed, mut failed) = (0usize, 0usize, 0u64, Vec::new()); |
|
| 219 | + | for name in &names { |
|
| 220 | + | match app.repos.write(name, |r, _| r.gc(false).map_err(crate::repos::RepoError::Oxy)).await { |
|
| 221 | + | Ok(rep) => { |
|
| 222 | + | packed += rep.packed.objects; |
|
| 223 | + | pruned += rep.pruned.objects; |
|
| 224 | + | freed += rep.pruned.bytes + rep.packed.raw_bytes.saturating_sub(rep.packed.packed_bytes); |
|
| 225 | + | } |
|
| 226 | + | Err(e) => failed.push(format!("{name}: {e}")), |
|
| 227 | + | } |
|
| 228 | + | } |
|
| 229 | + | let summary = format!( |
|
| 230 | + | "Maintained {} repositor{}: {packed} loose objects packed, {pruned} unreachable objects pruned, about {} saved.", |
|
| 231 | + | names.len(), |
|
| 232 | + | if names.len() == 1 { "y" } else { "ies" }, |
|
| 233 | + | crate::render::bytes(freed) |
|
| 234 | + | ); |
|
| 235 | + | accounts::audit(&app.db, Some(&me), "admin.gc", &f.repo, &summary).await.ok(); |
|
| 236 | + | let error = (!failed.is_empty()).then(|| failed.join("; ")); |
|
| 237 | + | storage_page(&app, shell, Some(summary), error).await |
|
| 238 | + | } |
modified crates/hub/src/web/mod.rs +5 −0
| … | 23 unchanged lines |
||
| 24 | 24 | mod session; |
|
| 25 | 25 | mod settings; |
|
| 26 | 26 | mod statics; |
|
| 27 | + | mod webhooks; |
|
| 27 | 28 | ||
| 28 | 29 | use crate::auth::{self, Access, Role, Viewer}; |
|
| 29 | 30 | use crate::repos::RepoError; |
|
| … | 41 unchanged lines |
||
| 71 | 72 | .route("/orgs/{name}/settings", get(orgs::settings).post(orgs::update)) |
|
| 72 | 73 | .route("/orgs/{name}/settings/members", post(orgs::member)) |
|
| 73 | 74 | .route("/orgs/{name}/settings/teams", post(orgs::team)) |
|
| 75 | + | .route("/orgs/{name}/settings/hooks", post(orgs::hooks)) |
|
| 76 | + | .route("/orgs/{name}/settings/hooks/{id}/deliveries", get(orgs::deliveries)) |
|
| 77 | + | .route("/orgs/{name}/settings/hooks/{id}/redeliver/{delivery}", post(orgs::redeliver)) |
|
| 74 | 78 | .route("/orgs/{name}/settings/avatar", post(orgs::avatar).layer(DefaultBodyLimit::max(avatar::MAX_BYTES + (64 << 10)))) |
|
| 75 | 79 | .route("/settings/avatar", post(settings::avatar).layer(DefaultBodyLimit::max(avatar::MAX_BYTES + (64 << 10)))) |
|
| 76 | 80 | .route("/notifications", get(inbox::inbox)) |
|
| … | 4 unchanged lines |
||
| 81 | 85 | .route("/admin/registration", post(admin::set_mode)) |
|
| 82 | 86 | .route("/admin/invite", post(admin::invite)) |
|
| 83 | 87 | .route("/admin/account", post(admin::account)) |
|
| 88 | + | .route("/admin/storage", get(admin::storage).post(admin::maintain)) |
|
| 84 | 89 | .nest("/api/v1", crate::api::router()) |
|
| 85 | 90 | .route("/{owner}", get(home::owner)) |
|
| 86 | 91 | .route("/{owner}/{name}", get(repo::overview)) |
|
| … | 482 unchanged lines |
modified crates/hub/src/web/orgs.rs +45 −1
| … | 60 unchanged lines |
||
| 61 | 61 | org: accounts::Organisation, |
|
| 62 | 62 | members: Vec<(accounts::Account, String)>, |
|
| 63 | 63 | teams: Vec<(accounts::Team, Vec<accounts::Account>)>, |
|
| 64 | + | hooks: Vec<crate::db::hooks::Hook>, |
|
| 64 | 65 | notice: Option<String>, |
|
| 65 | 66 | error: Option<String>, |
|
| 66 | 67 | } |
|
| … | 11 unchanged lines |
||
| 78 | 79 | let members = accounts::team_members(&app.db, t.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 79 | 80 | teams.push((t, members)); |
|
| 80 | 81 | } |
|
| 81 | - | Ok(Page(OrgSettingsPage { shell, org, members, teams, notice, error })) |
|
| 82 | + | let hooks = crate::db::hooks::hooks_for_org(&app.db, org.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 83 | + | Ok(Page(OrgSettingsPage { shell, org, members, teams, hooks, notice, error })) |
|
| 82 | 84 | } |
|
| 83 | 85 | ||
| 84 | 86 | async fn owned_org(app: &Shared, shell: &Shell, viewer: &Viewer, name: &str) -> Result<(accounts::Account, accounts::Organisation), Error> { |
|
| … | 166 unchanged lines |
||
| 251 | 253 | Ok(m) => org_page(&app, shell, org, Some(m), None).await, |
|
| 252 | 254 | Err(e) => org_page(&app, shell, org, None, Some(e)).await, |
|
| 253 | 255 | } |
|
| 256 | + | } |
|
| 257 | + | ||
| 258 | + | /// `POST /orgs/{name}/settings/hooks`: webhooks that hear every repository of the organisation. |
|
| 259 | + | pub async fn hooks( |
|
| 260 | + | State(app): State<Shared>, |
|
| 261 | + | viewer: Viewer, |
|
| 262 | + | Path(name): Path<String>, |
|
| 263 | + | Form(f): Form<super::webhooks::HookForm>, |
|
| 264 | + | ) -> Result<Page<OrgSettingsPage>, Error> { |
|
| 265 | + | let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer); |
|
| 266 | + | let (_, org) = owned_org(&app, &shell, &viewer, &name).await?; |
|
| 267 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 268 | + | let scope = super::webhooks::Scope::Org { id: org.id, name: org.name.clone() }; |
|
| 269 | + | match super::webhooks::apply(&app, &viewer, &scope, f).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 270 | + | Ok(notice) => org_page(&app, shell, org, Some(notice), None).await, |
|
| 271 | + | Err(error) => org_page(&app, shell, org, None, Some(error)).await, |
|
| 272 | + | } |
|
| 273 | + | } |
|
| 274 | + | ||
| 275 | + | pub async fn deliveries( |
|
| 276 | + | State(app): State<Shared>, |
|
| 277 | + | viewer: Viewer, |
|
| 278 | + | Path((name, id)): Path<(String, i64)>, |
|
| 279 | + | ) -> Result<Page<super::webhooks::DeliveriesPage>, Error> { |
|
| 280 | + | let shell = Shell::new(&app, &format!("/orgs/{name}/settings/hooks/{id}/deliveries"), &viewer); |
|
| 281 | + | let (_, org) = owned_org(&app, &shell, &viewer, &name).await?; |
|
| 282 | + | let scope = super::webhooks::Scope::Org { id: org.id, name: org.name }; |
|
| 283 | + | super::webhooks::deliveries_page(&app, shell, &scope, None, id).await |
|
| 284 | + | } |
|
| 285 | + | ||
| 286 | + | pub async fn redeliver( |
|
| 287 | + | State(app): State<Shared>, |
|
| 288 | + | viewer: Viewer, |
|
| 289 | + | Path((name, id, delivery)): Path<(String, i64, i64)>, |
|
| 290 | + | Form(f): Form<super::session::CsrfForm>, |
|
| 291 | + | ) -> Result<Page<super::webhooks::DeliveriesPage>, Error> { |
|
| 292 | + | let shell = Shell::new(&app, &format!("/orgs/{name}/settings/hooks/{id}/deliveries"), &viewer); |
|
| 293 | + | let (_, org) = owned_org(&app, &shell, &viewer, &name).await?; |
|
| 294 | + | csrf(&viewer, &f.csrf, &shell)?; |
|
| 295 | + | let scope = super::webhooks::Scope::Org { id: org.id, name: org.name }; |
|
| 296 | + | super::webhooks::redeliver(&app, &scope, id, delivery).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 297 | + | super::webhooks::deliveries_page(&app, shell, &scope, None, id).await |
|
| 254 | 298 | } |
modified crates/hub/src/web/repo_settings.rs +14 −89
| … | 292 unchanged lines |
||
| 293 | 293 | settings_page(&app, shell, row, role, Some("Landing requirements saved.".into()), None).await |
|
| 294 | 294 | } |
|
| 295 | 295 | ||
| 296 | - | #[derive(Deserialize)] |
|
| 297 | - | pub struct HookForm { |
|
| 298 | - | #[serde(rename = "_csrf")] |
|
| 299 | - | csrf_token: String, |
|
| 300 | - | #[serde(default)] |
|
| 301 | - | url: String, |
|
| 302 | - | #[serde(default)] |
|
| 303 | - | secret: String, |
|
| 304 | - | #[serde(default = "star")] |
|
| 305 | - | events: String, |
|
| 306 | - | #[serde(default)] |
|
| 307 | - | id: Option<i64>, |
|
| 308 | - | #[serde(default)] |
|
| 309 | - | action: String, |
|
| 310 | - | } |
|
| 311 | - | ||
| 312 | - | fn star() -> String { |
|
| 313 | - | "*".into() |
|
| 314 | - | } |
|
| 315 | - | ||
| 316 | 296 | pub async fn hooks( |
|
| 317 | 297 | State(app): State<Shared>, |
|
| 318 | 298 | viewer: Viewer, |
|
| 319 | 299 | Path((owner, name)): Path<(String, String)>, |
|
| 320 | - | Form(f): Form<HookForm>, |
|
| 300 | + | Form(f): Form<super::webhooks::HookForm>, |
|
| 321 | 301 | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 322 | 302 | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 323 | 303 | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 324 | 304 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 325 | - | let e = |e| Error::from_db(shell.clone(), e); |
|
| 326 | - | let notice = match (f.action.as_str(), f.id) { |
|
| 327 | - | ("delete", Some(id)) => { |
|
| 328 | - | if db::hooks::hook(&app.db, id).await.map_err(e)?.is_some_and(|h| h.repo_id == Some(row.id)) { |
|
| 329 | - | db::hooks::delete_hook(&app.db, id).await.map_err(e)?; |
|
| 330 | - | } |
|
| 331 | - | "Webhook deleted.".to_string() |
|
| 332 | - | } |
|
| 333 | - | ("toggle", Some(id)) => { |
|
| 334 | - | if let Some(h) = db::hooks::hook(&app.db, id).await.map_err(e)?.filter(|h| h.repo_id == Some(row.id)) { |
|
| 335 | - | db::hooks::set_hook_active(&app.db, id, !h.active).await.map_err(e)?; |
|
| 336 | - | } |
|
| 337 | - | "Webhook updated.".to_string() |
|
| 338 | - | } |
|
| 339 | - | ("ping", Some(id)) => { |
|
| 340 | - | if let Some(h) = db::hooks::hook(&app.db, id).await.map_err(e)?.filter(|h| h.repo_id == Some(row.id)) { |
|
| 341 | - | let delivery = crate::auth::random_hex(16); |
|
| 342 | - | let env = hub_api::WebhookEnvelope { |
|
| 343 | - | event: "ping".into(), |
|
| 344 | - | delivery: delivery.clone(), |
|
| 345 | - | repository: None, |
|
| 346 | - | actor: viewer.account().map(|a| a.username.clone()).unwrap_or_default(), |
|
| 347 | - | at_ms: db::now_ms(), |
|
| 348 | - | data: serde_json::json!({ "zen": "changes commute" }), |
|
| 349 | - | }; |
|
| 350 | - | db::hooks::queue_delivery(&app.db, h.id, &delivery, "ping", &serde_json::to_string(&env).unwrap_or_default()) |
|
| 351 | - | .await |
|
| 352 | - | .map_err(e)?; |
|
| 353 | - | app.events.notify_deliveries(); |
|
| 354 | - | } |
|
| 355 | - | "Ping queued.".to_string() |
|
| 356 | - | } |
|
| 357 | - | _ => { |
|
| 358 | - | if !(f.url.starts_with("http://") || f.url.starts_with("https://")) { |
|
| 359 | - | return settings_page(&app, shell, row, role, None, Some("A webhook URL starts with http:// or https://.".into())).await; |
|
| 360 | - | } |
|
| 361 | - | db::hooks::create_hook(&app.db, Some(row.id), None, f.url.trim(), f.secret.trim(), f.events.trim()).await.map_err(e)?; |
|
| 362 | - | accounts::audit(&app.db, viewer.account(), "hook.created", &format!("{owner}/{name}"), f.url.trim()).await.ok(); |
|
| 363 | - | "Webhook added.".to_string() |
|
| 364 | - | } |
|
| 365 | - | }; |
|
| 366 | - | settings_page(&app, shell, row, role, Some(notice), None).await |
|
| 367 | - | } |
|
| 368 | - | ||
| 369 | - | #[derive(Template)] |
|
| 370 | - | #[template(path = "deliveries.html")] |
|
| 371 | - | pub struct DeliveriesPage { |
|
| 372 | - | shell: Shell, |
|
| 373 | - | repo: RepoShell, |
|
| 374 | - | hook: db::hooks::Hook, |
|
| 375 | - | rows: Vec<db::hooks::Delivery>, |
|
| 305 | + | let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") }; |
|
| 306 | + | match super::webhooks::apply(&app, &viewer, &scope, f).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 307 | + | Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await, |
|
| 308 | + | Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await, |
|
| 309 | + | } |
|
| 376 | 310 | } |
|
| 377 | 311 | ||
| 378 | 312 | pub async fn deliveries( |
|
| 379 | 313 | State(app): State<Shared>, |
|
| 380 | 314 | viewer: Viewer, |
|
| 381 | 315 | Path((owner, name, id)): Path<(String, String, i64)>, |
|
| 382 | - | ) -> Result<Page<DeliveriesPage>, Error> { |
|
| 316 | + | ) -> Result<Page<super::webhooks::DeliveriesPage>, Error> { |
|
| 383 | 317 | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer); |
|
| 384 | 318 | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 385 | - | let hook = db::hooks::hook(&app.db, id) |
|
| 386 | - | .await |
|
| 387 | - | .map_err(|e| Error::from_db(shell.clone(), e))? |
|
| 388 | - | .filter(|h| h.repo_id == Some(row.id)) |
|
| 389 | - | .ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 390 | - | let rows = db::hooks::deliveries_for_hook(&app.db, id, 100).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 391 | - | Ok(Page(DeliveriesPage { shell, repo: RepoShell::new(&app, &row, "settings").with_role(role), hook, rows })) |
|
| 319 | + | let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") }; |
|
| 320 | + | super::webhooks::deliveries_page(&app, shell, &scope, Some(RepoShell::new(&app, &row, "settings").with_role(role)), id).await |
|
| 392 | 321 | } |
|
| 393 | 322 | ||
| 394 | 323 | pub async fn redeliver( |
|
| … | 1 unchanged lines |
||
| 396 | 325 | viewer: Viewer, |
|
| 397 | 326 | Path((owner, name, id, delivery)): Path<(String, String, i64, i64)>, |
|
| 398 | 327 | Form(f): Form<super::session::CsrfForm>, |
|
| 399 | - | ) -> Result<Page<DeliveriesPage>, Error> { |
|
| 328 | + | ) -> Result<Page<super::webhooks::DeliveriesPage>, Error> { |
|
| 400 | 329 | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings/hooks/{id}/deliveries"), &viewer); |
|
| 401 | - | let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 330 | + | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 402 | 331 | csrf(&viewer, &f.csrf, &shell)?; |
|
| 403 | - | let mine = db::hooks::hook(&app.db, id).await.ok().flatten().is_some_and(|h| h.repo_id == Some(row.id)); |
|
| 404 | - | if mine && db::hooks::delivery(&app.db, delivery).await.map_err(|e| Error::from_db(shell.clone(), e))?.is_some_and(|d| d.hook_id == id) |
|
| 405 | - | { |
|
| 406 | - | db::hooks::redeliver(&app.db, delivery).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 407 | - | app.events.notify_deliveries(); |
|
| 408 | - | } |
|
| 409 | - | deliveries(State(app), viewer, Path((owner, name, id))).await |
|
| 332 | + | let scope = super::webhooks::Scope::Repo { id: row.id, slug: format!("{owner}/{name}") }; |
|
| 333 | + | super::webhooks::redeliver(&app, &scope, id, delivery).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 334 | + | super::webhooks::deliveries_page(&app, shell, &scope, Some(RepoShell::new(&app, &row, "settings").with_role(role)), id).await |
|
| 410 | 335 | } |
|
| 411 | 336 | ||
| 412 | 337 | #[derive(Deserialize)] |
|
| … | 50 unchanged lines |
modified crates/hub/tests/accounts.rs +76 −0
| … | 390 unchanged lines |
||
| 391 | 391 | assert_eq!(r.status, StatusCode::NOT_FOUND, "not an owner"); |
|
| 392 | 392 | assert_eq!(picture(app, "acme").await.1, "image/png"); |
|
| 393 | 393 | } |
|
| 394 | + | ||
| 395 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 396 | + | async fn organisation_webhooks_and_storage() { |
|
| 397 | + | let h = hub().await; |
|
| 398 | + | let app = &h.app; |
|
| 399 | + | let andy = register(app, "andy", "").await; |
|
| 400 | + | let me = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap(); |
|
| 401 | + | let org = db::accounts::create_org(&app.db, "acme", "Acme", me.id).await.unwrap(); |
|
| 402 | + | ||
| 403 | + | // Organisation webhooks: add, ping, deliveries; only for this organisation. |
|
| 404 | + | let page = get(app, "/orgs/acme/settings", Some(&andy)).await; |
|
| 405 | + | assert!(page.body.contains("id=\"webhooks\"") && page.body.contains("href=\"#webhooks\"")); |
|
| 406 | + | let csrf = csrf_of(&page.body); |
|
| 407 | + | let r = post(app, "/orgs/acme/settings/hooks", Some(&andy), &format!("_csrf={csrf}&url=ftp%3A%2F%2Fx&events=*")).await; |
|
| 408 | + | assert!(r.body.contains("starts with http"), "{}", r.body); |
|
| 409 | + | let r = post( |
|
| 410 | + | app, |
|
| 411 | + | "/orgs/acme/settings/hooks", |
|
| 412 | + | Some(&andy), |
|
| 413 | + | &format!("_csrf={csrf}&url=https%3A%2F%2Fhooks.invalid%2Facme&secret=s&events=push"), |
|
| 414 | + | ) |
|
| 415 | + | .await; |
|
| 416 | + | assert!(r.body.contains("Webhook added")); |
|
| 417 | + | let hooks = db::hooks::hooks_for_org(&app.db, org.id).await.unwrap(); |
|
| 418 | + | assert_eq!(hooks.len(), 1); |
|
| 419 | + | let id = hooks[0].id; |
|
| 420 | + | assert!(post(app, "/orgs/acme/settings/hooks", Some(&andy), &format!("_csrf={csrf}&id={id}&action=ping")) |
|
| 421 | + | .await |
|
| 422 | + | .body |
|
| 423 | + | .contains("Ping queued")); |
|
| 424 | + | let deliveries = get(app, &format!("/orgs/acme/settings/hooks/{id}/deliveries"), Some(&andy)).await; |
|
| 425 | + | assert_eq!(deliveries.status, StatusCode::OK); |
|
| 426 | + | assert!(deliveries.body.contains(">ping<") && deliveries.body.contains("/orgs/acme/settings#webhooks")); |
|
| 427 | + | // A repository's hook cannot be touched through the organisation's form. |
|
| 428 | + | let repo = oxygenhub::repos::RepoName::parse("andy/demo").unwrap(); |
|
| 429 | + | app.repos.create(&repo, None).unwrap(); |
|
| 430 | + | let row = db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap(); |
|
| 431 | + | let repo_hook = db::hooks::create_hook(&app.db, Some(row.id), None, "https://hooks.invalid/repo", "", "*").await.unwrap().id; |
|
| 432 | + | post(app, "/orgs/acme/settings/hooks", Some(&andy), &format!("_csrf={csrf}&id={repo_hook}&action=delete")).await; |
|
| 433 | + | assert!(db::hooks::hook(&app.db, repo_hook).await.unwrap().is_some(), "not the organisation's"); |
|
| 434 | + | assert_eq!(get(app, &format!("/orgs/acme/settings/hooks/{repo_hook}/deliveries"), Some(&andy)).await.status, StatusCode::NOT_FOUND); |
|
| 435 | + | // The repository's own settings still manage its hooks (shared code). |
|
| 436 | + | let rs = get(app, "/andy/demo/settings", Some(&andy)).await; |
|
| 437 | + | assert!(rs.body.contains("https://hooks.invalid/repo") && rs.body.contains("action=\"/andy/demo/settings/hooks\"")); |
|
| 438 | + | ||
| 439 | + | // Storage: sizes and loose objects, and maintenance packs them. |
|
| 440 | + | let work = tempfile::tempdir().unwrap(); |
|
| 441 | + | let mut client = Repo::init(work.path()).unwrap(); |
|
| 442 | + | client.config.set("remote.origin.url", &format!("{}/andy/demo", h.base)).unwrap(); |
|
| 443 | + | let (token, hash) = oxygenhub::auth::new_token(); |
|
| 444 | + | db::accounts::create_token(&app.db, me.id, "push", &hash, "repo:write", "", None).await.unwrap(); |
|
| 445 | + | client.config.set("remote.origin.token", &token).unwrap(); |
|
| 446 | + | client.save_config().unwrap(); |
|
| 447 | + | write(work.path(), "a.txt", b"a\n"); |
|
| 448 | + | client.snapshot("t").unwrap(); |
|
| 449 | + | client.commit("one", &[]).unwrap(); |
|
| 450 | + | assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 451 | + | let storage = get(app, "/admin/storage", Some(&andy)).await; |
|
| 452 | + | assert_eq!(storage.status, StatusCode::OK); |
|
| 453 | + | assert!(storage.body.contains("href=\"/andy/demo\"")); |
|
| 454 | + | let loose_before = std::fs::read_dir(app.repos.dir(&repo).join(".oxy/objects/loose")).map(|d| d.count()).unwrap_or(0); |
|
| 455 | + | assert!(loose_before > 0, "the push left loose objects"); |
|
| 456 | + | let r = post(app, "/admin/storage", Some(&andy), &format!("_csrf={}&repo=andy%2Fdemo", csrf_of(&storage.body))).await; |
|
| 457 | + | assert!(r.body.contains("Maintained 1 repository") && r.body.contains("loose objects packed"), "{}", r.body); |
|
| 458 | + | let loose_after: usize = walk_files(&app.repos.dir(&repo).join(".oxy/objects/loose")); |
|
| 459 | + | assert_eq!(loose_after, 0, "packed"); |
|
| 460 | + | let audit = db::accounts::audit_rows(&app.db, 20).await.unwrap(); |
|
| 461 | + | assert!(audit.iter().any(|a| a.action == "admin.gc" && a.target == "andy/demo")); |
|
| 462 | + | let r = post(app, "/admin/storage", Some(&andy), &format!("_csrf={}&repo=nobody%2Fnone", csrf_of(&storage.body))).await; |
|
| 463 | + | assert!(r.body.contains("No repository nobody/none")); |
|
| 464 | + | } |
|
| 465 | + | ||
| 466 | + | fn walk_files(dir: &Path) -> usize { |
|
| 467 | + | let Ok(rd) = std::fs::read_dir(dir) else { return 0 }; |
|
| 468 | + | rd.flatten().map(|e| if e.path().is_dir() { walk_files(&e.path()) } else { 1 }).sum() |
|
| 469 | + | } |
modified docs/spec.md +2 −2
| … | 257 unchanged lines |
||
| 258 | 258 | | `/notifications` | Inbox (§10) | |
|
| 259 | 259 | | `/search` | §11 | |
|
| 260 | 260 | | `/help` | Getting started with this hub's own address, remote name and certificate filled in (identity, pushing, cloning, proposals, delegating to the hub, pipelines and stamps), the keyboard, and a glossary for people coming from GitHub. Linked from the header band, the footer and the keyboard sheet | |
|
| 261 | - | | `/admin/*` | Site: accounts, registration mode, storage, jobs, audit log | |
|
| 261 | + | | `/admin/*` | Site: accounts, registration mode, invitations, audit log; `/admin/storage` lists every repository's size on disk, loose objects and packs, and runs maintenance (`oxy gc` with the grace period kept, under the repository's write lock) on one repository or all, audited | |
|
| 262 | 262 | ||
| 263 | 263 | ### 6.4 Repository overview |
|
| 264 | 264 | ||
| … | 129 unchanged lines |
||
| 394 | 394 | ||
| 395 | 395 | ### 12.2 Webhooks |
|
| 396 | 396 | ||
| 397 | - | Per repository and per organisation: a URL, a secret, an event selection, active flag, and delivery log with redelivery. Payloads are JSON with `X-Oxyhub-Event`, `X-Oxyhub-Delivery`, and `X-Oxyhub-Signature: blake3-keyed=<hex>` computed as keyed BLAKE3 over the body. Events: `push` (reference updated: old and new state, heads, changes), `proposal` (opened, edited, review requested, landed, closed, conflicted), `review`, `comment`, `issue`, `release`, `check`, `policy`. Deliveries retry with backoff up to 24 hours. |
|
| 397 | + | Per repository and per organisation: a URL, a secret, an event selection, active flag, and delivery log with redelivery. Repository admins manage a repository's webhooks under its settings; organisation owners manage the organisation's under its settings, and those hear every repository the organisation owns. Payloads are JSON with `X-Oxyhub-Event`, `X-Oxyhub-Delivery`, and `X-Oxyhub-Signature: blake3-keyed=<hex>` computed as keyed BLAKE3 over the body. Events: `push` (reference updated: old and new state, heads, changes), `proposal` (opened, edited, review requested, landed, closed, conflicted), `review`, `comment`, `issue`, `release`, `check`, `policy`. Deliveries retry with backoff up to 24 hours. |
|
| 398 | 398 | ||
| 399 | 399 | ## 13. Checks and OxygenFlow |
|
| 400 | 400 | ||
| … | 167 unchanged lines |
modified templates/admin.html +1 −0
| … | 1 unchanged lines |
||
| 2 | 2 | {% block title %}Administration{% endblock %} |
|
| 3 | 3 | {% block content %} |
|
| 4 | 4 | <div class="head"><h1>Administration <small>{{ accounts.len() }} accounts · {{ orgs.len() }} organisations · {{ repos }} repositories</small></h1></div> |
|
| 5 | + | <p><a class="go small" href="/admin/storage">Storage and maintenance</a></p> |
|
| 5 | 6 | {% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %} |
|
| 6 | 7 | <div class="side-layout"> |
|
| 7 | 8 | <div> |
|
| … | 35 unchanged lines |
modified templates/deliveries.html +4 −4
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | 2 | {% import "repo_tabs.html" as m %} |
|
| 3 | - | {% block title %}Deliveries · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | - | {% block tabs %}{% call m::tabs(repo, "settings") %}{% endblock %} |
|
| 3 | + | {% block title %}Deliveries{% endblock %} |
|
| 4 | + | {% block tabs %}{% if let Some(r) = repo %}{% call m::tabs(r, "settings") %}{% endif %}{% endblock %} |
|
| 5 | 5 | {% block mainclass %} class="wide"{% endblock %} |
|
| 6 | 6 | {% block content %} |
|
| 7 | 7 | <div class="head"><h1>Deliveries <small>{{ hook.url }} · events {{ hook.events }}</small></h1></div> |
|
| 8 | - | <p><a href="{{ repo.url }}/settings">← settings</a></p> |
|
| 8 | + | <p><a href="{{ settings_url }}#webhooks">← 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> |
|
| … | 1 unchanged lines |
||
| 13 | 13 | <tr><td><time title="{{ d.created_ms|when_i }}">{{ d.created_ms|ago_i }}</time></td><td>{{ d.event }}</td><td><code>{{ d.delivery }}</code></td><td>{{ d.attempts }}</td> |
|
| 14 | 14 | <td>{% if let Some(s) = d.status %}{% if d.delivered_ms.is_some() %}<span class="sig sig-valid">✔</span>{% else %}<span class="sig sig-invalid">✘</span>{% endif %} {{ s }}{% else if d.attempts > 0 %}<span class="sig sig-invalid">✘</span> no response{% else %}<span class="mute">queued</span>{% endif %}</td> |
|
| 15 | 15 | <td class="small mute">{{ d.response|truncate(80) }}</td> |
|
| 16 | - | <td><form class="tiny" method="post" action="{{ repo.url }}/settings/hooks/{{ hook.id }}/redeliver/{{ d.id }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button>redeliver</button></form></td></tr> |
|
| 16 | + | <td><form class="tiny" method="post" action="{{ settings_url }}/hooks/{{ hook.id }}/redeliver/{{ d.id }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button>redeliver</button></form></td></tr> |
|
| 17 | 17 | {% endfor %} |
|
| 18 | 18 | </table></div> |
|
| 19 | 19 | {% endif %} |
|
| … | 1 unchanged lines |
modified templates/org_settings.html +3 −0
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | + | {% import "webhooks_panel.html" as w %} |
|
| 2 | 3 | {% block title %}{{ org.name }} settings{% endblock %} |
|
| 3 | 4 | {% block content %} |
|
| 4 | 5 | <div class="head"><h1>{{ org.name }} <small>organisation settings · <a href="/{{ org.name }}">repositories</a></small></h1></div> |
|
| … | 2 unchanged lines |
||
| 7 | 8 | <a href="#general">General</a> |
|
| 8 | 9 | <a href="#logo">Logo</a> |
|
| 9 | 10 | <a href="#members">Members</a> |
|
| 11 | + | <a href="#webhooks">Webhooks</a> |
|
| 10 | 12 | <a href="#teams">Teams</a> |
|
| 11 | 13 | </nav> |
|
| 12 | 14 | <div class="sections"> |
|
| … | 45 unchanged lines |
||
| 58 | 60 | <div class="choice"><button type="submit" name="action" value="create">CREATE TEAM</button> <button type="submit" name="action" value="add">ADD MEMBER</button> <button type="submit" name="action" value="remove">REMOVE MEMBER</button></div> |
|
| 59 | 61 | </form> |
|
| 60 | 62 | </section> |
|
| 63 | + | {% call w::panel("/orgs/" ~ org.name ~ "/settings", hooks, shell.csrf, "These hear every repository of the organisation, as well as each repository's own webhooks.") %} |
|
| 61 | 64 | </div> |
|
| 62 | 65 | </div> |
|
| 63 | 66 | {% endblock %} |
modified templates/repo_settings.html +2 −16
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | + | {% import "webhooks_panel.html" as w %} |
|
| 2 | 3 | {% import "repo_tabs.html" as m %} |
|
| 3 | 4 | {% block title %}Settings · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | 5 | {% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %} |
|
| … | 53 unchanged lines |
||
| 58 | 59 | <button type="submit">SAVE</button> |
|
| 59 | 60 | </form> |
|
| 60 | 61 | {% if role == crate::auth::Role::Admin %} |
|
| 61 | - | <section class="panel" id="webhooks"> |
|
| 62 | - | <h2>Webhooks</h2> |
|
| 63 | - | {% if hooks.is_empty() %}<p class="mute small">None.</p>{% else %} |
|
| 64 | - | <table class="small"><tr><th>URL</th><th>Events</th><th>Active</th><th></th></tr> |
|
| 65 | - | {% for h in hooks %}<tr><td>{{ h.url }}</td><td>{{ h.events }}</td><td>{% if h.active %}yes{% else %}no{% endif %}</td> |
|
| 66 | - | <td><a class="small" href="{{ repo.url }}/settings/hooks/{{ h.id }}/deliveries">deliveries</a> |
|
| 67 | - | <form class="tiny" method="post" action="{{ repo.url }}/settings/hooks"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="id" value="{{ h.id }}"><button name="action" value="ping">ping</button> <button name="action" value="toggle">{% if h.active %}disable{% else %}enable{% endif %}</button> <button name="action" value="delete">delete</button></form></td></tr>{% endfor %} |
|
| 68 | - | </table>{% endif %} |
|
| 69 | - | <form class="card" method="post" action="{{ repo.url }}/settings/hooks"> |
|
| 70 | - | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 71 | - | <label for="hurl">URL</label><input type="text" id="hurl" name="url" placeholder="https://…" required> |
|
| 72 | - | <label for="hsecret">Secret (signs each delivery)</label><input type="text" id="hsecret" name="secret"> |
|
| 73 | - | <label for="hevents">Events (<code>*</code>, or a list such as <code>push,proposal,issue</code>)</label><input type="text" id="hevents" name="events" value="*"> |
|
| 74 | - | <button type="submit">ADD WEBHOOK</button> |
|
| 75 | - | </form> |
|
| 76 | - | </section> |
|
| 62 | + | {% 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.") %} |
|
| 77 | 63 | <section class="panel" id="collaborators"> |
|
| 78 | 64 | <h2>Collaborators</h2> |
|
| 79 | 65 | {% if collaborators.is_empty() %}<p class="mute small">Nobody beyond the owner{% if !teams.is_empty() %} and the organisation's members{% endif %}.</p>{% else %} |
|
| … | 29 unchanged lines |