OXYGENHUB
◆ v0.5.1 oxygenhub / crates / hub / src / web / home.rs
17.5 KiB · Rust · last changed UI polish: keys, backups, wiki, editor, bookmarks, summaries, backdrops 22 days ago source blame history raw
d23464c0 Andy McCall ↶ 1
//! The front page, explore, owner pages and small endpoints.
2
3
use super::{filters, Error, Page, Shell};
19bb31ff Andy McCall ↶ 4
use crate::auth::Viewer;
d23464c0 Andy McCall ↶ 5
use crate::{db, Shared};
6
use askama::Template;
1553bfc9 Andy McCall ↶ 7
use axum::extract::{Path, Query, State};
d23464c0 Andy McCall ↶ 8
use axum::http::header;
9
use axum::response::{IntoResponse, Response};
10
use hub_api::Visibility;
1553bfc9 Andy McCall ↶ 11
use serde::Deserialize;
d23464c0 Andy McCall ↶ 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>,
1553bfc9 Andy McCall ↶ 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>,
0a3b244b Andy McCall ↶ 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,
1553bfc9 Andy McCall ↶ 28
}
29
30
impl HomePage {
31
    fn star_count(&self, id: &i64) -> i64 {
32
        self.stars.get(id).copied().unwrap_or(0)
33
    }
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,
d23464c0 Andy McCall ↶ 45
}
46
25ab8c73 Andy McCall ↶ 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>,
17ca6cdd Andy McCall ↶ 59
    /// Your sky's data (docs/backgrounds.md §5), JSON safe inside a script.
60
    backdrop: String,
25ab8c73 Andy McCall ↶ 61
}
62
63
/// How many activity lines the dashboard shows.
64
const FEED_LIMIT: i64 = 40;
df5c69cb Andy McCall ↶ 65
/// The most events Your sky reads, and how far back (docs/backgrounds.md §5).
66
const SKY_EVENTS: i64 = 1500;
0a3b244b Andy McCall ↶ 67
/// The most repositories the hub's sky draws, the most recently active.
68
const SKY_REPOS: usize = 40;
17ca6cdd Andy McCall ↶ 69
const SKY_DAYS: i64 = 30;
25ab8c73 Andy McCall ↶ 70
17ca6cdd Andy McCall ↶ 71
/// Your sky's data (docs/backgrounds.md §5): the viewer's repositories and
72
/// their events of the last 30 days, newest first, as
df5c69cb Andy McCall ↶ 73
/// `{now, repos: [slug], events: [[repo, kind, at_ms, n]]}` with kinds 0 a
74
/// push of `n` changes, 1 a proposal event, 2 an issue event, 3 a release,
75
/// 4 a Flow run. Only the database is read, never a repository's store;
76
/// `<` is escaped so the JSON is safe in a script.
da8f7826 Andy McCall ↶ 77
fn sky_json(mine: &[db::repos::RepoRow], events: &[db::activity::SkyEvent], now: i64, live: &str) -> String {
17ca6cdd Andy McCall ↶ 78
    let slugs: Vec<String> = mine.iter().map(|r| format!("{}/{}", r.owner, r.name)).collect();
79
    let evs: Vec<serde_json::Value> = events
80
        .iter()
81
        .filter_map(|e| {
df5c69cb Andy McCall ↶ 82
            let repo = mine.iter().position(|r| r.id == e.repo_id)?;
83
            Some(serde_json::json!([repo, e.kind, e.at_ms, e.n]))
17ca6cdd Andy McCall ↶ 84
        })
85
        .collect();
da8f7826 Andy McCall ↶ 86
    serde_json::json!({ "now": now, "repos": slugs, "events": evs, "live": live }).to_string().replace('<', "\\u003c")
17ca6cdd Andy McCall ↶ 87
}
88
ffb67a81 Andy McCall 89
/// One repository's sky (#196): the backdrop of its own pages, from the
90
/// database only, with no live polling. Empty when it cannot be read.
91
pub(crate) async fn repo_sky(app: &Shared, row: &db::repos::RepoRow) -> String {
92
    let now = db::now_ms();
93
    match db::activity::sky(&app.db, &[row.id], now - SKY_DAYS * 86_400_000, SKY_EVENTS).await {
94
        Ok(events) => sky_json(std::slice::from_ref(row), &events, now, ""),
95
        Err(_) => String::new(),
96
    }
97
}
98
da8f7826 Andy McCall ↶ 99
/// The repositories a sky draws: `mine`, the viewer's own (member) ones,
100
/// as the dashboard's; `hub`, the public ones the viewer may see, as
101
/// Explore's. Never a private repository in the hub's sky.
102
async fn sky_repos(app: &Shared, shell: &Shell, viewer: &Viewer, scene: &str) -> Result<Vec<db::repos::RepoRow>, Error> {
103
    let db_err = |e| Error::from_db(shell.clone(), e);
104
    let seen = visible(app, shell, viewer, db::repos::list(&app.db).await.map_err(db_err)?).await?;
105
    match (scene, viewer.account()) {
106
        ("mine", Some(account)) => {
107
            let member: Vec<i64> = db::accounts::visible_repo_ids(&app.db, account.id).await.map_err(db_err)?;
108
            Ok(seen.into_iter().filter(|r| member.contains(&r.id)).collect())
109
        }
110
        ("hub", _) => {
111
            let mut public: Vec<db::repos::RepoRow> = seen.into_iter().filter(|r| r.visibility == "public").collect();
112
            public.sort_by_key(|r| std::cmp::Reverse(r.updated_ms));
113
            public.truncate(SKY_REPOS);
114
            Ok(public)
115
        }
116
        _ => Ok(Vec::new()),
117
    }
118
}
119
120
#[derive(Deserialize)]
121
pub struct ArrivalsQuery {
122
    #[serde(default)]
123
    scene: String,
124
    #[serde(default)]
125
    since: i64,
126
}
127
128
/// `GET /sky/arrivals?scene=mine|hub&since=MS` (docs/backgrounds.md §4,
129
/// "Arrivals"): what happened in a sky's repositories since `since`, by
130
/// repository name, for the page to add without reloading. The same
131
/// repositories as the sky it feeds, never more.
132
pub async fn arrivals(State(app): State<Shared>, viewer: Viewer, Query(q): Query<ArrivalsQuery>) -> Result<Response, Error> {
133
    let shell = Shell::new(&app, "/sky/arrivals", &viewer);
134
    let repos = sky_repos(&app, &shell, &viewer, &q.scene).await?;
135
    let now = db::now_ms();
136
    // At most a day back: a page left open longer starts again.
137
    let since = q.since.clamp(now - 86_400_000, now);
138
    let ids: Vec<i64> = repos.iter().map(|r| r.id).collect();
139
    let events = if ids.is_empty() {
140
        Vec::new()
141
    } else {
142
        db::activity::sky(&app.db, &ids, since + 1, 200).await.map_err(|e| Error::from_db(shell.clone(), e))?
143
    };
144
    let evs: Vec<serde_json::Value> = events
145
        .iter()
146
        .filter_map(|e| {
147
            let r = repos.iter().find(|r| r.id == e.repo_id)?;
148
            Some(serde_json::json!([format!("{}/{}", r.owner, r.name), e.kind, e.at_ms, e.n]))
149
        })
150
        .collect();
151
    let body = serde_json::json!({ "now": now, "events": evs });
152
    Ok(([(axum::http::header::CACHE_CONTROL, "private, no-store")], axum::Json(body)).into_response())
153
}
154
25ab8c73 Andy McCall ↶ 155
/// `/`: the dashboard when signed in, every visible repository otherwise.
156
pub async fn index(State(app): State<Shared>, viewer: Viewer) -> Result<Response, Error> {
157
    let Some(account) = viewer.account().cloned() else {
1553bfc9 Andy McCall ↶ 158
        return Ok(explore(State(app), viewer, Query(ExploreQuery::default())).await?.into_response());
25ab8c73 Andy McCall ↶ 159
    };
19bb31ff Andy McCall ↶ 160
    let shell = Shell::new(&app, "/", &viewer);
25ab8c73 Andy McCall ↶ 161
    let db_err = |e| Error::from_db(shell.clone(), e);
162
    let all = db::repos::list(&app.db).await.map_err(db_err)?;
163
    let member: Vec<i64> = db::accounts::visible_repo_ids(&app.db, account.id).await.map_err(db_err)?;
164
    let seen = visible(&app, &shell, &viewer, all).await?;
165
    let seen_ids: Vec<i64> = seen.iter().map(|r| r.id).collect();
166
    let mut mine: Vec<db::repos::RepoRow> = seen.into_iter().filter(|r| member.contains(&r.id)).collect();
167
    mine.sort_by_key(|r| std::cmp::Reverse(r.updated_ms));
168
    let reviews = db::activity::awaiting_review(&app.db, account.id, &member).await.map_err(db_err)?;
169
    let authored = db::activity::authored(&app.db, account.id, &seen_ids).await.map_err(db_err)?;
170
    let assigned = db::activity::assigned(&app.db, account.id, &seen_ids).await.map_err(db_err)?;
171
    let feed = db::activity::feed(&app.db, &seen_ids, FEED_LIMIT).await.map_err(db_err)?;
17ca6cdd Andy McCall ↶ 172
    let mine_ids: Vec<i64> = mine.iter().map(|r| r.id).collect();
df5c69cb Andy McCall ↶ 173
    let now = db::now_ms();
174
    let sky = db::activity::sky(&app.db, &mine_ids, now - SKY_DAYS * 86_400_000, SKY_EVENTS).await.map_err(db_err)?;
da8f7826 Andy McCall ↶ 175
    let backdrop = sky_json(&mine, &sky, now, "mine");
17ca6cdd Andy McCall ↶ 176
    Ok(Page(Dashboard { shell, mine, reviews, authored, assigned, feed, backdrop }).into_response())
d23464c0 Andy McCall ↶ 177
}
178
1553bfc9 Andy McCall ↶ 179
/// `/explore`: every repository the viewer may see, filtered by text and
180
/// topic, sorted by recent activity (the default), stars or name.
181
pub async fn explore(State(app): State<Shared>, viewer: Viewer, Query(q): Query<ExploreQuery>) -> Result<Page<HomePage>, Error> {
25ab8c73 Andy McCall ↶ 182
    let shell = Shell::new(&app, "/explore", &viewer);
1553bfc9 Andy McCall ↶ 183
    let db_err = |e| Error::from_db(shell.clone(), e);
184
    let mut repos = visible(&app, &shell, &viewer, db::repos::list(&app.db).await.map_err(db_err)?).await?;
185
    let stars = db::repos::star_counts(&app.db).await.map_err(db_err)?;
186
    let needle = q.q.trim().to_lowercase();
187
    if !needle.is_empty() {
188
        repos.retain(|r| {
189
            r.slug().to_lowercase().contains(&needle)
190
                || r.description.to_lowercase().contains(&needle)
191
                || r.topic_list().iter().any(|t| t.contains(&needle))
192
        });
193
    }
194
    let topic = q.topic.trim().to_lowercase();
195
    if !topic.is_empty() {
196
        repos.retain(|r| r.topic_list().contains(&topic.as_str()));
197
    }
198
    let sort = match q.sort.as_str() {
199
        "stars" | "name" => q.sort.clone(),
200
        _ => "activity".to_string(),
201
    };
202
    match sort.as_str() {
203
        "stars" => repos.sort_by_key(|r| (std::cmp::Reverse(stars.get(&r.id).copied().unwrap_or(0)), std::cmp::Reverse(r.updated_ms))),
204
        "name" => repos.sort_by_key(|r| (r.name.to_lowercase(), r.owner.to_lowercase())),
205
        _ => repos.sort_by_key(|r| std::cmp::Reverse(r.updated_ms)),
206
    }
0a3b244b Andy McCall ↶ 207
    // The hub's sky: the public repositories listed (a filter narrows it),
208
    // the most recently active first; never a private one, whoever looks.
209
    let public: Vec<db::repos::RepoRow> = repos.iter().filter(|r| r.visibility == "public").take(SKY_REPOS).cloned().collect();
210
    let ids: Vec<i64> = public.iter().map(|r| r.id).collect();
211
    let now = db::now_ms();
212
    let sky = db::activity::sky(&app.db, &ids, now - SKY_DAYS * 86_400_000, SKY_EVENTS).await.map_err(db_err)?;
da8f7826 Andy McCall ↶ 213
    let backdrop = sky_json(&public, &sky, now, "hub");
0a3b244b Andy McCall ↶ 214
    Ok(Page(HomePage { description: app.config.site.description.clone(), shell, repos, owner: None, q: q.q, sort, topic, stars, backdrop }))
d23464c0 Andy McCall ↶ 215
}
216
10174045 Andy McCall ↶ 217
/// A person's page (hub spec §6): avatar, names, bio, linked signing
218
/// keys, organisations, repositories and what they did lately.
219
#[derive(Template)]
220
#[template(path = "profile.html")]
221
pub struct ProfilePage {
222
    shell: Shell,
223
    account: db::accounts::Account,
224
    keys: Vec<db::accounts::LinkedIdentity>,
225
    orgs: Vec<(db::accounts::Organisation, String)>,
226
    repos: Vec<db::repos::RepoRow>,
227
    feed: Vec<db::activity::Activity>,
228
    is_me: bool,
229
}
230
231
impl ProfilePage {
232
    /// The website as a link only when it is http(s).
233
    fn website_href(&self) -> Option<&str> {
234
        let w = self.account.website.trim();
235
        (w.starts_with("https://") || w.starts_with("http://")).then_some(w)
236
    }
237
}
238
239
/// An organisation's page: description, members with their roles, teams,
240
/// repositories and recent activity in them.
241
#[derive(Template)]
242
#[template(path = "org.html")]
243
pub struct OrgPage {
244
    shell: Shell,
245
    org: db::accounts::Organisation,
246
    members: Vec<(db::accounts::Account, String)>,
247
    teams: Vec<(db::accounts::Team, usize)>,
248
    repos: Vec<db::repos::RepoRow>,
249
    feed: Vec<db::activity::Activity>,
250
    can_admin: bool,
251
}
252
253
/// How many activity lines an owner page shows.
254
const OWNER_FEED: i64 = 20;
255
256
/// `/{owner}`: a person's profile, an organisation's page, or (for an
257
/// owner that is neither, such as repositories an administrator created
258
/// for a name nobody holds) the list of its repositories.
259
pub async fn owner(State(app): State<Shared>, viewer: Viewer, Path(owner): Path<String>) -> Result<Response, Error> {
19bb31ff Andy McCall ↶ 260
    let shell = Shell::new(&app, &format!("/{owner}"), &viewer);
d23464c0 Andy McCall ↶ 261
    if !crate::repos::names::valid_owner(&owner) {
262
        return Err(Error::not_found(shell));
263
    }
10174045 Andy McCall ↶ 264
    let db_err = |e| Error::from_db(shell.clone(), e);
265
    let mut repos = visible(&app, &shell, &viewer, db::repos::list_by_owner(&app.db, &owner).await.map_err(db_err)?).await?;
266
    repos.sort_by_key(|r| std::cmp::Reverse(r.updated_ms));
267
    if let Some(account) = db::accounts::account_by_name(&app.db, &owner).await.map_err(db_err)? {
268
        // What they did anywhere the viewer can see, not only in their own repositories.
269
        let all = visible(&app, &shell, &viewer, db::repos::list(&app.db).await.map_err(db_err)?).await?;
270
        let ids: Vec<i64> = all.iter().map(|r| r.id).collect();
271
        let feed = db::activity::feed_by(&app.db, &ids, Some(&account.username), OWNER_FEED).await.map_err(db_err)?;
272
        let keys = db::accounts::identities_of(&app.db, account.id).await.map_err(db_err)?;
273
        let orgs = db::accounts::orgs_of(&app.db, account.id).await.map_err(db_err)?;
274
        let is_me = viewer.account().is_some_and(|a| a.id == account.id);
275
        return Ok(Page(ProfilePage { shell, account, keys, orgs, repos, feed, is_me }).into_response());
276
    }
277
    if let Some(org) = db::accounts::org_by_name(&app.db, &owner).await.map_err(db_err)? {
278
        let members = db::accounts::org_members(&app.db, org.id).await.map_err(db_err)?;
279
        let mut teams = Vec::new();
280
        for t in db::accounts::teams(&app.db, org.id).await.map_err(db_err)? {
281
            let n = db::accounts::team_members(&app.db, t.id).await.map_err(db_err)?.len();
282
            teams.push((t, n));
283
        }
284
        let ids: Vec<i64> = repos.iter().map(|r| r.id).collect();
285
        let feed = db::activity::feed(&app.db, &ids, OWNER_FEED).await.map_err(db_err)?;
286
        let can_admin =
287
            viewer.is_admin() || viewer.account().is_some_and(|a| members.iter().any(|(m, role)| m.id == a.id && role == "owner"));
288
        return Ok(Page(OrgPage { shell, org, members, teams, repos, feed, can_admin }).into_response());
289
    }
290
    if repos.is_empty() {
d23464c0 Andy McCall ↶ 291
        return Err(Error::not_found(shell));
292
    }
1553bfc9 Andy McCall ↶ 293
    let stars = db::repos::star_counts(&app.db).await.unwrap_or_default();
294
    Ok(Page(HomePage {
295
        description: String::new(),
296
        shell,
297
        repos,
298
        owner: Some(owner),
299
        q: String::new(),
300
        sort: "activity".into(),
301
        topic: String::new(),
302
        stars,
0a3b244b Andy McCall ↶ 303
        backdrop: String::new(),
1553bfc9 Andy McCall ↶ 304
    })
305
    .into_response())
d23464c0 Andy McCall ↶ 306
}
307
19bb31ff Andy McCall ↶ 308
/// The repositories among `rows` the viewer may see: public ones, internal
309
/// ones when signed in, and those the viewer owns or collaborates on.
07b1a5bb Andy McCall ↶ 310
pub(crate) async fn visible(
311
    app: &Shared,
312
    shell: &Shell,
313
    viewer: &Viewer,
314
    rows: Vec<db::repos::RepoRow>,
315
) -> Result<Vec<db::repos::RepoRow>, Error> {
19bb31ff Andy McCall ↶ 316
    let mine: Vec<i64> = match viewer.account() {
317
        Some(a) if a.is_admin => rows.iter().map(|r| r.id).collect(),
318
        Some(a) => db::accounts::visible_repo_ids(&app.db, a.id).await.map_err(|e| Error::from_db(shell.clone(), e))?,
319
        None => vec![],
320
    };
321
    let signed_in = viewer.signed_in();
322
    Ok(rows
323
        .into_iter()
324
        .filter(|r| match r.visibility() {
325
            Visibility::Public => true,
326
            Visibility::Internal => signed_in,
327
            Visibility::Private => mine.contains(&r.id),
328
        })
329
        .collect())
330
}
331
d23464c0 Andy McCall ↶ 332
pub async fn health(State(app): State<Shared>) -> Response {
b2107275 Andy McCall ↶ 333
    let (states, weight, summaries) = app.repos.cache().stats();
d23464c0 Andy McCall ↶ 334
    let body = serde_json::json!({
335
        "ok": true,
336
        "version": env!("CARGO_PKG_VERSION"),
e9bfea8e Andy McCall ↶ 337
        "build": env!("OXY_BUILD_ID"),
d23464c0 Andy McCall ↶ 338
        "uptime_s": app.started.elapsed().as_secs(),
b2107275 Andy McCall ↶ 339
        // What the state cache holds, beside the process's size (#117).
340
        "state_cache": {
341
            "states": states,
342
            "estimated_mb": weight >> 20,
343
            "budget_mb": crate::repos::cache::budget() >> 20,
344
            "summaries": summaries,
345
        },
d23464c0 Andy McCall ↶ 346
    });
347
    axum::Json(body).into_response()
348
}
349
350
/// The hub delegate identity object (hub spec §15.5), as `oxy identity
351
/// export` writes one, so policy holders can admit it.
352
pub async fn hub_key(State(app): State<Shared>) -> Response {
353
    (
354
        [(header::CONTENT_TYPE, "application/oxy-identity"), (header::CONTENT_DISPOSITION, "attachment; filename=\"hub.identity\"")],
355
        app.hub_key.bytes.clone(),
356
    )
357
        .into_response()
358
}
8f3d2ccf Andy McCall ↶ 359
360
/// The hub's TLS certificate (hub spec §15.5), for clients of a
361
/// developer edition with a self-signed certificate: save it and point
362
/// `oxy` at it with `OXY_CA` or `oxy config remote.<name>.ca`.
363
pub async fn hub_ca(State(app): State<Shared>) -> Response {
364
    if !app.config.tls.self_signed {
365
        return (axum::http::StatusCode::NOT_FOUND, "this hub does not use a self-signed certificate").into_response();
366
    }
367
    match tokio::fs::read(app.config.tls_cert()).await {
368
        Ok(pem) => (
369
            [(header::CONTENT_TYPE, "application/x-pem-file"), (header::CONTENT_DISPOSITION, "attachment; filename=\"oxyhub-ca.pem\"")],
370
            pem,
371
        )
372
            .into_response(),
373
        Err(_) => (axum::http::StatusCode::NOT_FOUND, "no certificate").into_response(),
374
    }
e9bfea8e Andy McCall ↶ 375
}
376
377
/// What this hub is built from (hub spec §15): its version and build, and
378
/// the versions of the Oxygen and OxygenFlow libraries it links.
379
pub fn versions() -> serde_json::Value {
380
    serde_json::json!({
381
        "oxygenhub": { "version": env!("CARGO_PKG_VERSION"), "build": env!("OXY_BUILD_ID") },
382
        "oxy_core": oxy_core::VERSION,
383
        "flow_core": flow_core::VERSION,
384
    })
385
}
386
387
#[derive(askama::Template)]
388
#[template(path = "about.html")]
389
pub struct AboutPage {
390
    shell: Shell,
391
    oxy_core: &'static str,
392
    flow_core: &'static str,
393
    /// Runners and the versions they report, for administrators.
394
    runners: Vec<crate::db::runners::Runner>,
395
}
396
397
/// `GET /about`: the hub's version and what it is built on; runners too, for
398
/// administrators.
399
pub async fn about(State(app): State<Shared>, viewer: Viewer) -> Result<Page<AboutPage>, Error> {
400
    let shell = Shell::new(&app, "/about", &viewer);
401
    let runners = if viewer.is_admin() { crate::db::runners::list(&app.db).await.unwrap_or_default() } else { vec![] };
402
    Ok(Page(AboutPage { shell, oxy_core: oxy_core::VERSION, flow_core: flow_core::VERSION, runners }))
8f3d2ccf Andy McCall ↶ 403
}