OXYGENHUB

Coverage / crates/hub/src/web/home.rs 94.1%

320 of 340 lines run, 64 of 144 functions · run #488 on proposal/340 · run missed

1
//! The front page, explore, owner pages and small endpoints.
2
3
use super::{filters, Error, Page, Shell};
4
use crate::auth::Viewer;
5
use crate::{db, Shared};
6
use askama::Template;
7
use axum::extract::{Path, Query, State};
8
use axum::http::header;
9
use axum::response::{IntoResponse, Response};
10
use hub_api::Visibility;
11
use serde::Deserialize;
12
13
#[derive(Template)]
14
#[template(path = "home.html")]
15
pub struct HomePage {
16
    shell: Shell,
17
    description: String,
18
    repos: Vec<db::repos::RepoRow>,
19
    owner: Option<String>,
20
    /// Explore's controls: the filter text, the sort, the topic.
21
    q: String,
22
    sort: String,
23
    topic: String,
24
    stars: std::collections::HashMap<i64, i64>,
25
    /// The hub's sky (docs/backgrounds.md §2, #165) on Explore and the
26
    /// signed-out home: the public repositories listed; empty elsewhere.
27
    backdrop: String,
28
}
29
30
impl HomePage {
3122×
    fn star_count(&self, id: &i64) -> i64 {
3222×
        self.stars.get(id).copied().unwrap_or(0)
3322×
    }
34
}
35
36
/// `/explore?q=&sort=activity|stars|name&topic=`.
37
#[derive(Deserialize, Default)]
38
pub struct ExploreQuery {
39
    #[serde(default)]
40
    q: String,
41
    #[serde(default)]
42
    sort: String,
43
    #[serde(default)]
44
    topic: String,
45
}
46
47
/// The signed-in dashboard (hub spec §6): the review queue, the
48
/// viewer's own proposals and assigned issues, their repositories and
49
/// recent activity across everything they can see.
50
#[derive(Template)]
51
#[template(path = "dashboard.html")]
52
pub struct Dashboard {
53
    shell: Shell,
54
    mine: Vec<db::repos::RepoRow>,
55
    reviews: Vec<db::activity::ProposalLine>,
56
    authored: Vec<db::activity::ProposalLine>,
57
    assigned: Vec<db::activity::IssueLine>,
58
    feed: Vec<db::activity::Activity>,
59
    /// Your sky's data (docs/backgrounds.md §5), JSON safe inside a script.
60
    backdrop: String,
61
    /// What the cards and the feed listen on (docs/live.md L4): the
62
    /// viewer's topic and their repositories'.
63
    live: String,
64
}
65
66
/// How many activity lines the dashboard shows.
67
const FEED_LIMIT: i64 = 40;
68
/// The most events Your sky reads, and how far back (docs/backgrounds.md §5).
69
const SKY_EVENTS: i64 = 1500;
70
/// The most repositories the hub's sky draws, the most recently active.
71
const SKY_REPOS: usize = 40;
72
const SKY_DAYS: i64 = 30;
73
74
/// Your sky's data (docs/backgrounds.md §5): the viewer's repositories and
75
/// their events of the last 30 days, newest first, as
76
/// `{now, repos: [slug], events: [[repo, kind, at_ms, n]]}` with kinds 0 a
77
/// push of `n` changes, 1 a proposal event, 2 an issue event, 3 a release,
78
/// 4 a Flow run. Only the database is read, never a repository's store;
79
/// `<` is escaped so the JSON is safe in a script.
80167×
fn sky_json(mine: &[db::repos::RepoRow], events: &[db::activity::SkyEvent], now: i64, live: &str) -> String {
81167×
    let slugs: Vec<String> = mine.iter().map(|r| format!("{}/{}", r.owner, r.name)).collect();
82167×
    let evs: Vec<serde_json::Value> = events
83167×
        .iter()
841030×
        .filter_map(|e| {
851033×
            let repo = mine.iter().position(|r| r.id == e.repo_id)?;
861030×
            Some(serde_json::json!([repo, e.kind, e.at_ms, e.n]))
871030×
        })
88167×
        .collect();
89167×
    serde_json::json!({ "now": now, "repos": slugs, "events": evs, "live": live }).to_string().replace('<', "\\u003c")
90167×
}
91
92
/// One repository's sky (#196): the backdrop of its own pages, from the
93
/// database only, with no live polling. Empty when it cannot be read.
94143×
pub(crate) async fn repo_sky(app: &Shared, row: &db::repos::RepoRow) -> String {
95143×
    let now = db::now_ms();
96143×
    match db::activity::sky(&app.db, &[row.id], now - SKY_DAYS * 86_400_000, SKY_EVENTS).await {
97143×
        Ok(events) => sky_json(std::slice::from_ref(row), &events, now, ""),
980×
        Err(_) => String::new(),
99
    }
100143×
}
101
102
/// The repositories a sky draws: `mine`, the viewer's own (member) ones,
103
/// as the dashboard's; `hub`, the public ones the viewer may see, as
104
/// Explore's. Never a private repository in the hub's sky.
1056×
async fn sky_repos(app: &Shared, shell: &Shell, viewer: &Viewer, scene: &str) -> Result<Vec<db::repos::RepoRow>, Error> {
1066×
    let db_err = |e| Error::from_db(shell.clone(), e);
1076×
    let seen = visible(app, shell, viewer, db::repos::list(&app.db).await.map_err(db_err)?).await?;
1086×
    match (scene, viewer.account()) {
1096×
        ("mine", Some(account)) => {
1103×
            let member: Vec<i64> = db::accounts::visible_repo_ids(&app.db, account.id).await.map_err(db_err)?;
1115×
            Ok(seen.into_iter().filter(|r| member.contains(&r.id)).collect())
112
        }
1132×
        ("hub", _) => {
1143×
            let mut public: Vec<db::repos::RepoRow> = seen.into_iter().filter(|r| r.visibility == "public").collect();
1152×
            public.sort_by_key(|r| std::cmp::Reverse(r.updated_ms));
1162×
            public.truncate(SKY_REPOS);
1172×
            Ok(public)
118
        }
1191×
        _ => Ok(Vec::new()),
120
    }
1216×
}
122
123
#[derive(Deserialize)]
124
pub struct ArrivalsQuery {
125
    #[serde(default)]
126
    scene: String,
127
    #[serde(default)]
128
    since: i64,
129
}
130
131
/// `GET /sky/arrivals?scene=mine|hub&since=MS` (docs/backgrounds.md §4,
132
/// "Arrivals"): what happened in a sky's repositories since `since`, by
133
/// repository name, for the page to add without reloading. The same
134
/// repositories as the sky it feeds, never more.
1356×
pub async fn arrivals(State(app): State<Shared>, viewer: Viewer, Query(q): Query<ArrivalsQuery>) -> Result<Response, Error> {
1366×
    let shell = Shell::new(&app, "/sky/arrivals", &viewer);
1376×
    let repos = sky_repos(&app, &shell, &viewer, &q.scene).await?;
1386×
    let now = db::now_ms();
139
    // At most a day back: a page left open longer starts again.
1406×
    let since = q.since.clamp(now - 86_400_000, now);
1416×
    let ids: Vec<i64> = repos.iter().map(|r| r.id).collect();
1426×
    let events = if ids.is_empty() {
1432×
        Vec::new()
144
    } else {
1454×
        db::activity::sky(&app.db, &ids, since + 1, 200).await.map_err(|e| Error::from_db(shell.clone(), e))?
146
    };
1476×
    let evs: Vec<serde_json::Value> = events
1486×
        .iter()
1496×
        .filter_map(|e| {
1505×
            let r = repos.iter().find(|r| r.id == e.repo_id)?;
1514×
            Some(serde_json::json!([format!("{}/{}", r.owner, r.name), e.kind, e.at_ms, e.n]))
1524×
        })
1536×
        .collect();
1546×
    let body = serde_json::json!({ "now": now, "events": evs });
1556×
    Ok(([(axum::http::header::CACHE_CONTROL, "private, no-store")], axum::Json(body)).into_response())
1566×
}
157
158
/// `/`: the dashboard when signed in, every visible repository otherwise.
15911×
pub async fn index(State(app): State<Shared>, viewer: Viewer) -> Result<Response, Error> {
16011×
    let Some(account) = viewer.account().cloned() else {
1613×
        return Ok(explore(State(app), viewer, Query(ExploreQuery::default())).await?.into_response());
162
    };
1638×
    let shell = Shell::new(&app, "/", &viewer);
1648×
    let db_err = |e| Error::from_db(shell.clone(), e);
1658×
    let all = db::repos::list(&app.db).await.map_err(db_err)?;
1668×
    let member: Vec<i64> = db::accounts::visible_repo_ids(&app.db, account.id).await.map_err(db_err)?;
1678×
    let seen = visible(&app, &shell, &viewer, all).await?;
1688×
    let seen_ids: Vec<i64> = seen.iter().map(|r| r.id).collect();
1698×
    let mut mine: Vec<db::repos::RepoRow> = seen.into_iter().filter(|r| member.contains(&r.id)).collect();
1708×
    mine.sort_by_key(|r| std::cmp::Reverse(r.updated_ms));
1718×
    let reviews = db::activity::awaiting_review(&app.db, account.id, &member).await.map_err(db_err)?;
1728×
    let authored = db::activity::authored(&app.db, account.id, &seen_ids).await.map_err(db_err)?;
1738×
    let assigned = db::activity::assigned(&app.db, account.id, &seen_ids).await.map_err(db_err)?;
1748×
    let feed = db::activity::feed(&app.db, &seen_ids, FEED_LIMIT).await.map_err(db_err)?;
1758×
    let mine_ids: Vec<i64> = mine.iter().map(|r| r.id).collect();
1768×
    let now = db::now_ms();
1778×
    let sky = db::activity::sky(&app.db, &mine_ids, now - SKY_DAYS * 86_400_000, SKY_EVENTS).await.map_err(db_err)?;
1788×
    let backdrop = sky_json(&mine, &sky, now, "mine");
1798×
    let live = std::iter::once(shell.live_user.clone())
1808×
        .chain(mine.iter().take(15).map(|r| crate::events::topic::repo(r.id)))
18115×
        .filter(|t| !t.is_empty())
1828×
        .collect::<Vec<_>>()
1838×
        .join(" ");
1848×
    Ok(Page(Dashboard { shell, mine, reviews, authored, assigned, feed, backdrop, live }).into_response())
18511×
}
186
187
/// `/explore`: every repository the viewer may see, filtered by text and
188
/// topic, sorted by recent activity (the default), stars or name.
18916×
pub async fn explore(State(app): State<Shared>, viewer: Viewer, Query(q): Query<ExploreQuery>) -> Result<Page<HomePage>, Error> {
19016×
    let shell = Shell::new(&app, "/explore", &viewer);
19116×
    let db_err = |e| Error::from_db(shell.clone(), e);
19216×
    let mut repos = visible(&app, &shell, &viewer, db::repos::list(&app.db).await.map_err(db_err)?).await?;
19316×
    let stars = db::repos::star_counts(&app.db).await.map_err(db_err)?;
19416×
    let needle = q.q.trim().to_lowercase();
19516×
    if !needle.is_empty() {
1963×
        repos.retain(|r| {
1973×
            r.slug().to_lowercase().contains(&needle)
1983×
                || r.description.to_lowercase().contains(&needle)
1992×
                || r.topic_list().iter().any(|t| t.contains(&needle))
2003×
        });
20115×
    }
20216×
    let topic = q.topic.trim().to_lowercase();
20316×
    if !topic.is_empty() {
2043×
        repos.retain(|r| r.topic_list().contains(&topic.as_str()));
20515×
    }
20616×
    let sort = match q.sort.as_str() {
20716×
        "stars" | "name" => q.sort.clone(),
20814×
        _ => "activity".to_string(),
209
    };
21016×
    match sort.as_str() {
21116×
        "stars" => repos.sort_by_key(|r| (std::cmp::Reverse(stars.get(&r.id).copied().unwrap_or(0)), std::cmp::Reverse(r.updated_ms))),
21215×
        "name" => repos.sort_by_key(|r| (r.name.to_lowercase(), r.owner.to_lowercase())),
21314×
        _ => repos.sort_by_key(|r| std::cmp::Reverse(r.updated_ms)),
214
    }
215
    // The hub's sky: the public repositories listed (a filter narrows it),
216
    // the most recently active first; never a private one, whoever looks.
21716×
    let public: Vec<db::repos::RepoRow> = repos.iter().filter(|r| r.visibility == "public").take(SKY_REPOS).cloned().collect();
21816×
    let ids: Vec<i64> = public.iter().map(|r| r.id).collect();
21916×
    let now = db::now_ms();
22016×
    let sky = db::activity::sky(&app.db, &ids, now - SKY_DAYS * 86_400_000, SKY_EVENTS).await.map_err(db_err)?;
22116×
    let backdrop = sky_json(&public, &sky, now, "hub");
22216×
    Ok(Page(HomePage { description: app.config.site.description.clone(), shell, repos, owner: None, q: q.q, sort, topic, stars, backdrop }))
22316×
}
224
225
/// A person's page (hub spec §6): avatar, names, bio, linked signing
226
/// keys, organisations, repositories and what they did lately.
227
#[derive(Template)]
228
#[template(path = "profile.html")]
229
pub struct ProfilePage {
230
    shell: Shell,
231
    account: db::accounts::Account,
232
    keys: Vec<db::accounts::LinkedIdentity>,
233
    orgs: Vec<(db::accounts::Organisation, String)>,
234
    repos: Vec<db::repos::RepoRow>,
235
    feed: Vec<db::activity::Activity>,
236
    is_me: bool,
237
    /// The year's pulse as JSON for the page's script (#217); `<` escaped.
238
    pulse: String,
239
    /// Changes pushed in the last year, shown beside the pulse.
240
    pulse_total: i64,
241
}
242
243
impl ProfilePage {
244
    /// The website as a link only when it is http(s).
24511×
    fn website_href(&self) -> Option<&str> {
24611×
        let w = self.account.website.trim();
24711×
        (w.starts_with("https://") || w.starts_with("http://")).then_some(w)
24811×
    }
249
}
250
251
/// An organisation's page: description, members with their roles, teams,
252
/// repositories and recent activity in them.
253
#[derive(Template)]
254
#[template(path = "org.html")]
255
pub struct OrgPage {
256
    shell: Shell,
257
    org: db::accounts::Organisation,
258
    members: Vec<(db::accounts::Account, String)>,
259
    teams: Vec<(db::accounts::Team, usize)>,
260
    repos: Vec<db::repos::RepoRow>,
261
    feed: Vec<db::activity::Activity>,
262
    can_admin: bool,
263
}
264
265
const DAY_MS: i64 = 86_400_000;
266
267
/// The profile's pulse (#217): changes this account pushed each day of the
268
/// last year, in repositories the viewer may see, with the share pushed
269
/// through agent tokens, per repository, and its releases to label peaks.
270
/// `{"start": MS, "repos": [NAME], "days": [[TOTAL, VIA, [[REPO, N]..]]..],
271
/// "peaks": [[DAY, LABEL]..], "months": M}`: the last 3, 6, 9 or 12
272
/// months, the shortest that holds their first push of the year, ending
273
/// today (UTC).
27411×
async fn pulse_json(app: &Shared, account: &db::accounts::Account, seen: &[db::repos::RepoRow]) -> sqlx::Result<(String, i64)> {
27511×
    let today = db::now_ms() / DAY_MS;
27611×
    let ids: Vec<i64> = seen.iter().map(|r| r.id).collect();
27711×
    let rows = db::activity::pulse(&app.db, account.id, &ids, (today - 364) * DAY_MS).await?;
278
    // Snap to 3, 6, 9 or 12 months: no point drawing a year for a month's work.
27911×
    let used = rows.first().map(|r| today - r.1 / DAY_MS + 1).unwrap_or(0);
28011×
    let (months, span) = [(3, 91), (6, 182), (9, 273), (12, 365)].into_iter().find(|(_, d)| used <= *d).unwrap_or((12, 365));
28111×
    let first = today - span + 1;
28211×
    let (pulse, total) = pulse_of(&rows, seen, first, span as usize);
28311×
    let mut value = pulse;
28411×
    let mut peaks = Vec::new();
28511×
    for (repo_id, tag, at) in db::activity::releases_by(&app.db, account.id, &ids, first * DAY_MS).await? {
2861×
        let name = seen.iter().find(|r| r.id == repo_id).map(|r| r.name.as_str()).unwrap_or("");
2871×
        peaks.push(serde_json::json!([at / DAY_MS - first, format!("{name} {tag}")]));
288
    }
28911×
    value["peaks"] = serde_json::Value::Array(peaks);
29011×
    value["months"] = months.into();
29111×
    Ok((value.to_string().replace('<', "\\u003c"), total))
29211×
}
293
294
/// The days of a pulse from push rows `(repo_id, at_ms, changes, via)`:
295
/// repositories ordered by how much went to them, most first.
29611×
fn pulse_of(rows: &[(i64, i64, i64, String)], seen: &[db::repos::RepoRow], first: i64, span: usize) -> (serde_json::Value, i64) {
29711×
    let mut per_repo: std::collections::BTreeMap<i64, i64> = std::collections::BTreeMap::new();
29811×
    for (repo, _, n, _) in rows {
2993×
        *per_repo.entry(*repo).or_default() += n;
3003×
    }
30111×
    let mut order: Vec<(i64, i64)> = per_repo.into_iter().collect();
30211×
    order.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
30311×
    let names: Vec<String> = order
30411×
        .iter()
30511×
        .map(|(id, _)| seen.iter().find(|r| r.id == *id).map(|r| format!("{}/{}", r.owner, r.name)).unwrap_or_default())
30611×
        .collect();
30711×
    let slot = |id: i64| order.iter().position(|(r, _)| *r == id).unwrap_or(0);
30811×
    let mut days: Vec<(i64, i64, std::collections::BTreeMap<usize, i64>)> = vec![(0, 0, Default::default()); span];
30911×
    let mut total = 0;
31011×
    for (repo, at, n, via) in rows {
3113×
        let d = at / DAY_MS - first;
3123×
        if d < 0 || d as usize >= span {
3130×
            continue;
3143×
        }
3153×
        let day = &mut days[d as usize];
3163×
        day.0 += n;
3173×
        if !via.is_empty() {
3181×
            day.1 += n;
3192×
        }
3203×
        *day.2.entry(slot(*repo)).or_default() += n;
3213×
        total += n;
322
    }
32311×
    let days: Vec<serde_json::Value> = days
32411×
        .into_iter()
3251001×
        .map(|(t, v, by)| serde_json::json!([t, v, by.into_iter().map(|(r, n)| serde_json::json!([r, n])).collect::<Vec<_>>()]))
32611×
        .collect();
32711×
    (serde_json::json!({ "start": first * DAY_MS, "repos": names, "days": days }), total)
32811×
}
329
330
/// How many activity lines an owner page shows.
331
const OWNER_FEED: i64 = 20;
332
333
/// `/{owner}`: a person's profile, an organisation's page, or (for an
334
/// owner that is neither, such as repositories an administrator created
335
/// for a name nobody holds) the list of its repositories.
33616×
pub async fn owner(State(app): State<Shared>, viewer: Viewer, Path(owner): Path<String>) -> Result<Response, Error> {
33716×
    let shell = Shell::new(&app, &format!("/{owner}"), &viewer);
33816×
    if !crate::repos::names::valid_owner(&owner) {
3390×
        return Err(Error::not_found(shell));
34016×
    }
34116×
    let db_err = |e| Error::from_db(shell.clone(), e);
34216×
    let mut repos = visible(&app, &shell, &viewer, db::repos::list_by_owner(&app.db, &owner).await.map_err(db_err)?).await?;
34316×
    repos.sort_by_key(|r| std::cmp::Reverse(r.updated_ms));
34416×
    if let Some(account) = db::accounts::account_by_name(&app.db, &owner).await.map_err(db_err)? {
345
        // What they did anywhere the viewer can see, not only in their own repositories.
34611×
        let all = visible(&app, &shell, &viewer, db::repos::list(&app.db).await.map_err(db_err)?).await?;
34711×
        let ids: Vec<i64> = all.iter().map(|r| r.id).collect();
34811×
        let feed = db::activity::feed_by(&app.db, &ids, Some(&account.username), OWNER_FEED).await.map_err(db_err)?;
34911×
        let keys = db::accounts::identities_of(&app.db, account.id).await.map_err(db_err)?;
35011×
        let orgs = db::accounts::orgs_of(&app.db, account.id).await.map_err(db_err)?;
35111×
        let is_me = viewer.account().is_some_and(|a| a.id == account.id);
35211×
        let (pulse, pulse_total) = pulse_json(&app, &account, &all).await.map_err(db_err)?;
35311×
        return Ok(Page(ProfilePage { shell, account, keys, orgs, repos, feed, is_me, pulse, pulse_total }).into_response());
3545×
    }
3555×
    if let Some(org) = db::accounts::org_by_name(&app.db, &owner).await.map_err(db_err)? {
3562×
        let members = db::accounts::org_members(&app.db, org.id).await.map_err(db_err)?;
3572×
        let mut teams = Vec::new();
3582×
        for t in db::accounts::teams(&app.db, org.id).await.map_err(db_err)? {
3592×
            let n = db::accounts::team_members(&app.db, t.id).await.map_err(db_err)?.len();
3602×
            teams.push((t, n));
361
        }
3622×
        let ids: Vec<i64> = repos.iter().map(|r| r.id).collect();
3632×
        let feed = db::activity::feed(&app.db, &ids, OWNER_FEED).await.map_err(db_err)?;
3642×
        let can_admin =
3652×
            viewer.is_admin() || viewer.account().is_some_and(|a| members.iter().any(|(m, role)| m.id == a.id && role == "owner"));
3662×
        return Ok(Page(OrgPage { shell, org, members, teams, repos, feed, can_admin }).into_response());
3673×
    }
3683×
    if repos.is_empty() {
3692×
        return Err(Error::not_found(shell));
3701×
    }
3711×
    let stars = db::repos::star_counts(&app.db).await.unwrap_or_default();
3721×
    Ok(Page(HomePage {
3731×
        description: String::new(),
3741×
        shell,
3751×
        repos,
3761×
        owner: Some(owner),
3771×
        q: String::new(),
3781×
        sort: "activity".into(),
3791×
        topic: String::new(),
3801×
        stars,
3811×
        backdrop: String::new(),
3821×
    })
3831×
    .into_response())
38416×
}
385
386
/// The repositories among `rows` the viewer may see: public ones, internal
387
/// ones when signed in, and those the viewer owns or collaborates on.
388110×
pub(crate) async fn visible(
389110×
    app: &Shared,
390110×
    shell: &Shell,
391110×
    viewer: &Viewer,
392110×
    rows: Vec<db::repos::RepoRow>,
393110×
) -> Result<Vec<db::repos::RepoRow>, Error> {
394110×
    let mine: Vec<i64> = match viewer.account() {
39530×
        Some(a) if a.is_admin => rows.iter().map(|r| r.id).collect(),
39623×
        Some(a) => db::accounts::visible_repo_ids(&app.db, a.id).await.map_err(|e| Error::from_db(shell.clone(), e))?,
39757×
        None => vec![],
398
    };
399110×
    let signed_in = viewer.signed_in();
400110×
    Ok(rows
401110×
        .into_iter()
402110×
        .filter(|r| match r.visibility() {
40343×
            Visibility::Public => true,
4040×
            Visibility::Internal => signed_in,
40527×
            Visibility::Private => mine.contains(&r.id),
40670×
        })
407110×
        .collect())
408110×
}
409
4105×
pub async fn health(State(app): State<Shared>) -> Response {
4115×
    let (states, weight, summaries) = app.repos.cache().stats();
4125×
    let strata = oxy_strata::stats();
4135×
    let body = serde_json::json!({
4145×
        "ok": true,
4155×
        "version": env!("CARGO_PKG_VERSION"),
4165×
        "build": env!("OXY_BUILD_ID"),
4175×
        "uptime_s": app.started.elapsed().as_secs(),
418
        // What this process does, and the singleton tasks it leads (#272).
4195×
        "roles": app.roles.names(),
4205×
        "leading": app.leader.leading(),
421
        // What the state cache holds, beside the process's size (#117).
4225×
        "state_cache": {
4235×
            "states": states,
4245×
            "estimated_mb": weight >> 20,
4255×
            "budget_mb": crate::repos::cache::budget() >> 20,
4265×
            "summaries": summaries,
427
        },
428
        // Repositories kept in buckets (#234): clients opened, requests
429
        // made (in all and by kind) and the time spent in them since the
430
        // hub started.
4315×
        "cloud_stores": {
4325×
            "opened": strata.opens,
4335×
            "requests": strata.calls,
4345×
            "request_ms": strata.millis,
4355×
            "by_kind": strata
4365×
                .ops()
43760×
                .map(|(k, n)| (k.to_string(), n.into()))
4385×
                .collect::<serde_json::Map<_, _>>(),
439
        },
440
    });
4415×
    axum::Json(body).into_response()
4425×
}
443
444
/// The hub delegate identity object (hub spec §15.5), as `oxy identity
445
/// export` writes one, so policy holders can admit it.
44628×
pub async fn hub_key(State(app): State<Shared>) -> Response {
44728×
    (
44828×
        [(header::CONTENT_TYPE, "application/oxy-identity"), (header::CONTENT_DISPOSITION, "attachment; filename=\"hub.identity\"")],
44928×
        app.hub_key.bytes.clone(),
45028×
    )
45128×
        .into_response()
45228×
}
453
454
/// The hub's TLS certificate (hub spec §15.5), for clients of a
455
/// developer edition with a self-signed certificate: save it and point
456
/// `oxy` at it with `OXY_CA` or `oxy config remote.<name>.ca`.
4571×
pub async fn hub_ca(State(app): State<Shared>) -> Response {
4581×
    if !app.config.tls.self_signed {
4591×
        return (axum::http::StatusCode::NOT_FOUND, "this hub does not use a self-signed certificate").into_response();
4600×
    }
4610×
    match tokio::fs::read(app.config.tls_cert()).await {
4620×
        Ok(pem) => (
4630×
            [(header::CONTENT_TYPE, "application/x-pem-file"), (header::CONTENT_DISPOSITION, "attachment; filename=\"oxyhub-ca.pem\"")],
4640×
            pem,
4650×
        )
4660×
            .into_response(),
4670×
        Err(_) => (axum::http::StatusCode::NOT_FOUND, "no certificate").into_response(),
468
    }
4691×
}
470
471
/// What this hub is built from (hub spec §15): its version and build, and
472
/// the versions of the Oxygen and OxygenFlow libraries it links.
4731×
pub fn versions() -> serde_json::Value {
4741×
    serde_json::json!({
4751×
        "oxygenhub": { "version": env!("CARGO_PKG_VERSION"), "build": env!("OXY_BUILD_ID") },
4761×
        "oxy_core": oxy_core::VERSION,
4771×
        "flow_core": flow_core::VERSION,
478
    })
4791×
}
480
481
#[derive(askama::Template)]
482
#[template(path = "about.html")]
483
pub struct AboutPage {
484
    shell: Shell,
485
    oxy_core: &'static str,
486
    flow_core: &'static str,
487
    /// Runners and the versions they report, for administrators.
488
    runners: Vec<crate::db::runners::Runner>,
489
}
490
491
/// `GET /about`: the hub's version and what it is built on; runners too, for
492
/// administrators.
4931×
pub async fn about(State(app): State<Shared>, viewer: Viewer) -> Result<Page<AboutPage>, Error> {
4941×
    let shell = Shell::new(&app, "/about", &viewer);
4951×
    let runners = if viewer.is_admin() { crate::db::runners::list(&app.db).await.unwrap_or_default() } else { vec![] };
4961×
    Ok(Page(AboutPage { shell, oxy_core: oxy_core::VERSION, flow_core: flow_core::VERSION, runners }))
4971×
}