OXYGENHUB

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