OXYGENHUB
# 417184c73f4e oxygenhub / crates / hub / src / web / home.rs
13.5 KiB · Rust · last changed feat(web): Backdrops, the engine in Rust and WASM, and the dashboard's Your sky (refs #157) 22 days ago source blame history raw
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
}
26
27
impl HomePage {
28
    fn star_count(&self, id: &i64) -> i64 {
29
        self.stars.get(id).copied().unwrap_or(0)
30
    }
31
}
32
33
/// `/explore?q=&sort=activity|stars|name&topic=`.
34
#[derive(Deserialize, Default)]
35
pub struct ExploreQuery {
36
    #[serde(default)]
37
    q: String,
38
    #[serde(default)]
39
    sort: String,
40
    #[serde(default)]
41
    topic: String,
42
}
43
44
/// The signed-in dashboard (hub spec §6): the review queue, the
45
/// viewer's own proposals and assigned issues, their repositories and
46
/// recent activity across everything they can see.
47
#[derive(Template)]
48
#[template(path = "dashboard.html")]
49
pub struct Dashboard {
50
    shell: Shell,
51
    mine: Vec<db::repos::RepoRow>,
52
    reviews: Vec<db::activity::ProposalLine>,
53
    authored: Vec<db::activity::ProposalLine>,
54
    assigned: Vec<db::activity::IssueLine>,
55
    feed: Vec<db::activity::Activity>,
56
    /// Your sky's data (docs/backgrounds.md §5), JSON safe inside a script.
57
    backdrop: String,
58
}
59
60
/// How many activity lines the dashboard shows.
61
const FEED_LIMIT: i64 = 40;
62
/// The most events Your sky draws, and how far back (docs/backgrounds.md §5).
63
const SKY_EVENTS: i64 = 400;
64
const SKY_DAYS: i64 = 30;
65
66
/// Your sky's data (docs/backgrounds.md §5): the viewer's repositories and
67
/// their events of the last 30 days, newest first, as
68
/// `{now, repos: [slug], events: [[repo, kind, at_ms]]}` with kinds 0 push,
69
/// 1 proposal, 2 issue, 3 release. Only the database is read, never a
70
/// repository's store; `<` is escaped so the JSON is safe in a script.
71
fn sky_json(mine: &[db::repos::RepoRow], events: &[db::activity::Activity], now: i64) -> String {
72
    let slugs: Vec<String> = mine.iter().map(|r| format!("{}/{}", r.owner, r.name)).collect();
73
    let since = now - SKY_DAYS * 86_400_000;
74
    let evs: Vec<serde_json::Value> = events
75
        .iter()
76
        .filter(|e| e.at_ms >= since)
77
        .filter_map(|e| {
78
            let repo = slugs.iter().position(|s| *s == format!("{}/{}", e.owner, e.name))?;
79
            let kind = match e.kind.as_str() {
80
                "proposal" => 1,
81
                "issue" => 2,
82
                "release" => 3,
83
                _ => 0,
84
            };
85
            Some(serde_json::json!([repo, kind, e.at_ms]))
86
        })
87
        .collect();
88
    serde_json::json!({ "now": now, "repos": slugs, "events": evs }).to_string().replace('<', "\\u003c")
89
}
90
91
/// `/`: the dashboard when signed in, every visible repository otherwise.
92
pub async fn index(State(app): State<Shared>, viewer: Viewer) -> Result<Response, Error> {
93
    let Some(account) = viewer.account().cloned() else {
94
        return Ok(explore(State(app), viewer, Query(ExploreQuery::default())).await?.into_response());
95
    };
96
    let shell = Shell::new(&app, "/", &viewer);
97
    let db_err = |e| Error::from_db(shell.clone(), e);
98
    let all = db::repos::list(&app.db).await.map_err(db_err)?;
99
    let member: Vec<i64> = db::accounts::visible_repo_ids(&app.db, account.id).await.map_err(db_err)?;
100
    let seen = visible(&app, &shell, &viewer, all).await?;
101
    let seen_ids: Vec<i64> = seen.iter().map(|r| r.id).collect();
102
    let mut mine: Vec<db::repos::RepoRow> = seen.into_iter().filter(|r| member.contains(&r.id)).collect();
103
    mine.sort_by_key(|r| std::cmp::Reverse(r.updated_ms));
104
    let reviews = db::activity::awaiting_review(&app.db, account.id, &member).await.map_err(db_err)?;
105
    let authored = db::activity::authored(&app.db, account.id, &seen_ids).await.map_err(db_err)?;
106
    let assigned = db::activity::assigned(&app.db, account.id, &seen_ids).await.map_err(db_err)?;
107
    let feed = db::activity::feed(&app.db, &seen_ids, FEED_LIMIT).await.map_err(db_err)?;
108
    let mine_ids: Vec<i64> = mine.iter().map(|r| r.id).collect();
109
    let sky = db::activity::feed(&app.db, &mine_ids, SKY_EVENTS).await.map_err(db_err)?;
110
    let backdrop = sky_json(&mine, &sky, db::now_ms());
111
    Ok(Page(Dashboard { shell, mine, reviews, authored, assigned, feed, backdrop }).into_response())
112
}
113
114
/// `/explore`: every repository the viewer may see, filtered by text and
115
/// topic, sorted by recent activity (the default), stars or name.
116
pub async fn explore(State(app): State<Shared>, viewer: Viewer, Query(q): Query<ExploreQuery>) -> Result<Page<HomePage>, Error> {
117
    let shell = Shell::new(&app, "/explore", &viewer);
118
    let db_err = |e| Error::from_db(shell.clone(), e);
119
    let mut repos = visible(&app, &shell, &viewer, db::repos::list(&app.db).await.map_err(db_err)?).await?;
120
    let stars = db::repos::star_counts(&app.db).await.map_err(db_err)?;
121
    let needle = q.q.trim().to_lowercase();
122
    if !needle.is_empty() {
123
        repos.retain(|r| {
124
            r.slug().to_lowercase().contains(&needle)
125
                || r.description.to_lowercase().contains(&needle)
126
                || r.topic_list().iter().any(|t| t.contains(&needle))
127
        });
128
    }
129
    let topic = q.topic.trim().to_lowercase();
130
    if !topic.is_empty() {
131
        repos.retain(|r| r.topic_list().contains(&topic.as_str()));
132
    }
133
    let sort = match q.sort.as_str() {
134
        "stars" | "name" => q.sort.clone(),
135
        _ => "activity".to_string(),
136
    };
137
    match sort.as_str() {
138
        "stars" => repos.sort_by_key(|r| (std::cmp::Reverse(stars.get(&r.id).copied().unwrap_or(0)), std::cmp::Reverse(r.updated_ms))),
139
        "name" => repos.sort_by_key(|r| (r.name.to_lowercase(), r.owner.to_lowercase())),
140
        _ => repos.sort_by_key(|r| std::cmp::Reverse(r.updated_ms)),
141
    }
142
    Ok(Page(HomePage { description: app.config.site.description.clone(), shell, repos, owner: None, q: q.q, sort, topic, stars }))
143
}
144
145
/// A person's page (hub spec §6): avatar, names, bio, linked signing
146
/// keys, organisations, repositories and what they did lately.
147
#[derive(Template)]
148
#[template(path = "profile.html")]
149
pub struct ProfilePage {
150
    shell: Shell,
151
    account: db::accounts::Account,
152
    keys: Vec<db::accounts::LinkedIdentity>,
153
    orgs: Vec<(db::accounts::Organisation, String)>,
154
    repos: Vec<db::repos::RepoRow>,
155
    feed: Vec<db::activity::Activity>,
156
    is_me: bool,
157
}
158
159
impl ProfilePage {
160
    /// The website as a link only when it is http(s).
161
    fn website_href(&self) -> Option<&str> {
162
        let w = self.account.website.trim();
163
        (w.starts_with("https://") || w.starts_with("http://")).then_some(w)
164
    }
165
}
166
167
/// An organisation's page: description, members with their roles, teams,
168
/// repositories and recent activity in them.
169
#[derive(Template)]
170
#[template(path = "org.html")]
171
pub struct OrgPage {
172
    shell: Shell,
173
    org: db::accounts::Organisation,
174
    members: Vec<(db::accounts::Account, String)>,
175
    teams: Vec<(db::accounts::Team, usize)>,
176
    repos: Vec<db::repos::RepoRow>,
177
    feed: Vec<db::activity::Activity>,
178
    can_admin: bool,
179
}
180
181
/// How many activity lines an owner page shows.
182
const OWNER_FEED: i64 = 20;
183
184
/// `/{owner}`: a person's profile, an organisation's page, or (for an
185
/// owner that is neither, such as repositories an administrator created
186
/// for a name nobody holds) the list of its repositories.
187
pub async fn owner(State(app): State<Shared>, viewer: Viewer, Path(owner): Path<String>) -> Result<Response, Error> {
188
    let shell = Shell::new(&app, &format!("/{owner}"), &viewer);
189
    if !crate::repos::names::valid_owner(&owner) {
190
        return Err(Error::not_found(shell));
191
    }
192
    let db_err = |e| Error::from_db(shell.clone(), e);
193
    let mut repos = visible(&app, &shell, &viewer, db::repos::list_by_owner(&app.db, &owner).await.map_err(db_err)?).await?;
194
    repos.sort_by_key(|r| std::cmp::Reverse(r.updated_ms));
195
    if let Some(account) = db::accounts::account_by_name(&app.db, &owner).await.map_err(db_err)? {
196
        // What they did anywhere the viewer can see, not only in their own repositories.
197
        let all = visible(&app, &shell, &viewer, db::repos::list(&app.db).await.map_err(db_err)?).await?;
198
        let ids: Vec<i64> = all.iter().map(|r| r.id).collect();
199
        let feed = db::activity::feed_by(&app.db, &ids, Some(&account.username), OWNER_FEED).await.map_err(db_err)?;
200
        let keys = db::accounts::identities_of(&app.db, account.id).await.map_err(db_err)?;
201
        let orgs = db::accounts::orgs_of(&app.db, account.id).await.map_err(db_err)?;
202
        let is_me = viewer.account().is_some_and(|a| a.id == account.id);
203
        return Ok(Page(ProfilePage { shell, account, keys, orgs, repos, feed, is_me }).into_response());
204
    }
205
    if let Some(org) = db::accounts::org_by_name(&app.db, &owner).await.map_err(db_err)? {
206
        let members = db::accounts::org_members(&app.db, org.id).await.map_err(db_err)?;
207
        let mut teams = Vec::new();
208
        for t in db::accounts::teams(&app.db, org.id).await.map_err(db_err)? {
209
            let n = db::accounts::team_members(&app.db, t.id).await.map_err(db_err)?.len();
210
            teams.push((t, n));
211
        }
212
        let ids: Vec<i64> = repos.iter().map(|r| r.id).collect();
213
        let feed = db::activity::feed(&app.db, &ids, OWNER_FEED).await.map_err(db_err)?;
214
        let can_admin =
215
            viewer.is_admin() || viewer.account().is_some_and(|a| members.iter().any(|(m, role)| m.id == a.id && role == "owner"));
216
        return Ok(Page(OrgPage { shell, org, members, teams, repos, feed, can_admin }).into_response());
217
    }
218
    if repos.is_empty() {
219
        return Err(Error::not_found(shell));
220
    }
221
    let stars = db::repos::star_counts(&app.db).await.unwrap_or_default();
222
    Ok(Page(HomePage {
223
        description: String::new(),
224
        shell,
225
        repos,
226
        owner: Some(owner),
227
        q: String::new(),
228
        sort: "activity".into(),
229
        topic: String::new(),
230
        stars,
231
    })
232
    .into_response())
233
}
234
235
/// The repositories among `rows` the viewer may see: public ones, internal
236
/// ones when signed in, and those the viewer owns or collaborates on.
237
pub(crate) async fn visible(
238
    app: &Shared,
239
    shell: &Shell,
240
    viewer: &Viewer,
241
    rows: Vec<db::repos::RepoRow>,
242
) -> Result<Vec<db::repos::RepoRow>, Error> {
243
    let mine: Vec<i64> = match viewer.account() {
244
        Some(a) if a.is_admin => rows.iter().map(|r| r.id).collect(),
245
        Some(a) => db::accounts::visible_repo_ids(&app.db, a.id).await.map_err(|e| Error::from_db(shell.clone(), e))?,
246
        None => vec![],
247
    };
248
    let signed_in = viewer.signed_in();
249
    Ok(rows
250
        .into_iter()
251
        .filter(|r| match r.visibility() {
252
            Visibility::Public => true,
253
            Visibility::Internal => signed_in,
254
            Visibility::Private => mine.contains(&r.id),
255
        })
256
        .collect())
257
}
258
259
pub async fn health(State(app): State<Shared>) -> Response {
260
    let body = serde_json::json!({
261
        "ok": true,
262
        "version": env!("CARGO_PKG_VERSION"),
263
        "build": env!("OXY_BUILD_ID"),
264
        "uptime_s": app.started.elapsed().as_secs(),
265
    });
266
    axum::Json(body).into_response()
267
}
268
269
/// The hub delegate identity object (hub spec §15.5), as `oxy identity
270
/// export` writes one, so policy holders can admit it.
271
pub async fn hub_key(State(app): State<Shared>) -> Response {
272
    (
273
        [(header::CONTENT_TYPE, "application/oxy-identity"), (header::CONTENT_DISPOSITION, "attachment; filename=\"hub.identity\"")],
274
        app.hub_key.bytes.clone(),
275
    )
276
        .into_response()
277
}
278
279
/// The hub's TLS certificate (hub spec §15.5), for clients of a
280
/// developer edition with a self-signed certificate: save it and point
281
/// `oxy` at it with `OXY_CA` or `oxy config remote.<name>.ca`.
282
pub async fn hub_ca(State(app): State<Shared>) -> Response {
283
    if !app.config.tls.self_signed {
284
        return (axum::http::StatusCode::NOT_FOUND, "this hub does not use a self-signed certificate").into_response();
285
    }
286
    match tokio::fs::read(app.config.tls_cert()).await {
287
        Ok(pem) => (
288
            [(header::CONTENT_TYPE, "application/x-pem-file"), (header::CONTENT_DISPOSITION, "attachment; filename=\"oxyhub-ca.pem\"")],
289
            pem,
290
        )
291
            .into_response(),
292
        Err(_) => (axum::http::StatusCode::NOT_FOUND, "no certificate").into_response(),
293
    }
294
}
295
296
/// What this hub is built from (hub spec §15): its version and build, and
297
/// the versions of the Oxygen and OxygenFlow libraries it links.
298
pub fn versions() -> serde_json::Value {
299
    serde_json::json!({
300
        "oxygenhub": { "version": env!("CARGO_PKG_VERSION"), "build": env!("OXY_BUILD_ID") },
301
        "oxy_core": oxy_core::VERSION,
302
        "flow_core": flow_core::VERSION,
303
    })
304
}
305
306
#[derive(askama::Template)]
307
#[template(path = "about.html")]
308
pub struct AboutPage {
309
    shell: Shell,
310
    oxy_core: &'static str,
311
    flow_core: &'static str,
312
    /// Runners and the versions they report, for administrators.
313
    runners: Vec<crate::db::runners::Runner>,
314
}
315
316
/// `GET /about`: the hub's version and what it is built on; runners too, for
317
/// administrators.
318
pub async fn about(State(app): State<Shared>, viewer: Viewer) -> Result<Page<AboutPage>, Error> {
319
    let shell = Shell::new(&app, "/about", &viewer);
320
    let runners = if viewer.is_admin() { crate::db::runners::list(&app.db).await.unwrap_or_default() } else { vec![] };
321
    Ok(Page(AboutPage { shell, oxy_core: oxy_core::VERSION, flow_core: flow_core::VERSION, runners }))
322
}