//! The front page, explore, owner pages and small endpoints. use super::{filters, Error, Page, Shell}; use crate::auth::Viewer; use crate::{db, Shared}; use askama::Template; use axum::extract::{Path, Query, State}; use axum::http::header; use axum::response::{IntoResponse, Response}; use hub_api::Visibility; use serde::Deserialize; #[derive(Template)] #[template(path = "home.html")] pub struct HomePage { shell: Shell, description: String, repos: Vec, owner: Option, /// Explore's controls: the filter text, the sort, the topic. q: String, sort: String, topic: String, stars: std::collections::HashMap, /// The hub's sky (docs/backgrounds.md §2, #165) on Explore and the /// signed-out home: the public repositories listed; empty elsewhere. backdrop: String, } impl HomePage { fn star_count(&self, id: &i64) -> i64 { self.stars.get(id).copied().unwrap_or(0) } } /// `/explore?q=&sort=activity|stars|name&topic=`. #[derive(Deserialize, Default)] pub struct ExploreQuery { #[serde(default)] q: String, #[serde(default)] sort: String, #[serde(default)] topic: String, } /// The signed-in dashboard (hub spec §6): the review queue, the /// viewer's own proposals and assigned issues, their repositories and /// recent activity across everything they can see. #[derive(Template)] #[template(path = "dashboard.html")] pub struct Dashboard { shell: Shell, mine: Vec, reviews: Vec, authored: Vec, assigned: Vec, feed: Vec, /// Your sky's data (docs/backgrounds.md §5), JSON safe inside a script. backdrop: String, } /// How many activity lines the dashboard shows. const FEED_LIMIT: i64 = 40; /// The most events Your sky reads, and how far back (docs/backgrounds.md §5). const SKY_EVENTS: i64 = 1500; /// The most repositories the hub's sky draws, the most recently active. const SKY_REPOS: usize = 40; const SKY_DAYS: i64 = 30; /// Your sky's data (docs/backgrounds.md §5): the viewer's repositories and /// their events of the last 30 days, newest first, as /// `{now, repos: [slug], events: [[repo, kind, at_ms, n]]}` with kinds 0 a /// push of `n` changes, 1 a proposal event, 2 an issue event, 3 a release, /// 4 a Flow run. Only the database is read, never a repository's store; /// `<` is escaped so the JSON is safe in a script. fn sky_json(mine: &[db::repos::RepoRow], events: &[db::activity::SkyEvent], now: i64, live: &str) -> String { let slugs: Vec = mine.iter().map(|r| format!("{}/{}", r.owner, r.name)).collect(); let evs: Vec = events .iter() .filter_map(|e| { let repo = mine.iter().position(|r| r.id == e.repo_id)?; Some(serde_json::json!([repo, e.kind, e.at_ms, e.n])) }) .collect(); serde_json::json!({ "now": now, "repos": slugs, "events": evs, "live": live }).to_string().replace('<', "\\u003c") } /// One repository's sky (#196): the backdrop of its own pages, from the /// database only, with no live polling. Empty when it cannot be read. pub(crate) async fn repo_sky(app: &Shared, row: &db::repos::RepoRow) -> String { let now = db::now_ms(); match db::activity::sky(&app.db, &[row.id], now - SKY_DAYS * 86_400_000, SKY_EVENTS).await { Ok(events) => sky_json(std::slice::from_ref(row), &events, now, ""), Err(_) => String::new(), } } /// The repositories a sky draws: `mine`, the viewer's own (member) ones, /// as the dashboard's; `hub`, the public ones the viewer may see, as /// Explore's. Never a private repository in the hub's sky. async fn sky_repos(app: &Shared, shell: &Shell, viewer: &Viewer, scene: &str) -> Result, Error> { let db_err = |e| Error::from_db(shell.clone(), e); let seen = visible(app, shell, viewer, db::repos::list(&app.db).await.map_err(db_err)?).await?; match (scene, viewer.account()) { ("mine", Some(account)) => { let member: Vec = db::accounts::visible_repo_ids(&app.db, account.id).await.map_err(db_err)?; Ok(seen.into_iter().filter(|r| member.contains(&r.id)).collect()) } ("hub", _) => { let mut public: Vec = seen.into_iter().filter(|r| r.visibility == "public").collect(); public.sort_by_key(|r| std::cmp::Reverse(r.updated_ms)); public.truncate(SKY_REPOS); Ok(public) } _ => Ok(Vec::new()), } } #[derive(Deserialize)] pub struct ArrivalsQuery { #[serde(default)] scene: String, #[serde(default)] since: i64, } /// `GET /sky/arrivals?scene=mine|hub&since=MS` (docs/backgrounds.md §4, /// "Arrivals"): what happened in a sky's repositories since `since`, by /// repository name, for the page to add without reloading. The same /// repositories as the sky it feeds, never more. pub async fn arrivals(State(app): State, viewer: Viewer, Query(q): Query) -> Result { let shell = Shell::new(&app, "/sky/arrivals", &viewer); let repos = sky_repos(&app, &shell, &viewer, &q.scene).await?; let now = db::now_ms(); // At most a day back: a page left open longer starts again. let since = q.since.clamp(now - 86_400_000, now); let ids: Vec = repos.iter().map(|r| r.id).collect(); let events = if ids.is_empty() { Vec::new() } else { db::activity::sky(&app.db, &ids, since + 1, 200).await.map_err(|e| Error::from_db(shell.clone(), e))? }; let evs: Vec = events .iter() .filter_map(|e| { let r = repos.iter().find(|r| r.id == e.repo_id)?; Some(serde_json::json!([format!("{}/{}", r.owner, r.name), e.kind, e.at_ms, e.n])) }) .collect(); let body = serde_json::json!({ "now": now, "events": evs }); Ok(([(axum::http::header::CACHE_CONTROL, "private, no-store")], axum::Json(body)).into_response()) } /// `/`: the dashboard when signed in, every visible repository otherwise. pub async fn index(State(app): State, viewer: Viewer) -> Result { let Some(account) = viewer.account().cloned() else { return Ok(explore(State(app), viewer, Query(ExploreQuery::default())).await?.into_response()); }; let shell = Shell::new(&app, "/", &viewer); let db_err = |e| Error::from_db(shell.clone(), e); let all = db::repos::list(&app.db).await.map_err(db_err)?; let member: Vec = db::accounts::visible_repo_ids(&app.db, account.id).await.map_err(db_err)?; let seen = visible(&app, &shell, &viewer, all).await?; let seen_ids: Vec = seen.iter().map(|r| r.id).collect(); let mut mine: Vec = seen.into_iter().filter(|r| member.contains(&r.id)).collect(); mine.sort_by_key(|r| std::cmp::Reverse(r.updated_ms)); let reviews = db::activity::awaiting_review(&app.db, account.id, &member).await.map_err(db_err)?; let authored = db::activity::authored(&app.db, account.id, &seen_ids).await.map_err(db_err)?; let assigned = db::activity::assigned(&app.db, account.id, &seen_ids).await.map_err(db_err)?; let feed = db::activity::feed(&app.db, &seen_ids, FEED_LIMIT).await.map_err(db_err)?; let mine_ids: Vec = mine.iter().map(|r| r.id).collect(); let now = db::now_ms(); let sky = db::activity::sky(&app.db, &mine_ids, now - SKY_DAYS * 86_400_000, SKY_EVENTS).await.map_err(db_err)?; let backdrop = sky_json(&mine, &sky, now, "mine"); Ok(Page(Dashboard { shell, mine, reviews, authored, assigned, feed, backdrop }).into_response()) } /// `/explore`: every repository the viewer may see, filtered by text and /// topic, sorted by recent activity (the default), stars or name. pub async fn explore(State(app): State, viewer: Viewer, Query(q): Query) -> Result, Error> { let shell = Shell::new(&app, "/explore", &viewer); let db_err = |e| Error::from_db(shell.clone(), e); let mut repos = visible(&app, &shell, &viewer, db::repos::list(&app.db).await.map_err(db_err)?).await?; let stars = db::repos::star_counts(&app.db).await.map_err(db_err)?; let needle = q.q.trim().to_lowercase(); if !needle.is_empty() { repos.retain(|r| { r.slug().to_lowercase().contains(&needle) || r.description.to_lowercase().contains(&needle) || r.topic_list().iter().any(|t| t.contains(&needle)) }); } let topic = q.topic.trim().to_lowercase(); if !topic.is_empty() { repos.retain(|r| r.topic_list().contains(&topic.as_str())); } let sort = match q.sort.as_str() { "stars" | "name" => q.sort.clone(), _ => "activity".to_string(), }; match sort.as_str() { "stars" => repos.sort_by_key(|r| (std::cmp::Reverse(stars.get(&r.id).copied().unwrap_or(0)), std::cmp::Reverse(r.updated_ms))), "name" => repos.sort_by_key(|r| (r.name.to_lowercase(), r.owner.to_lowercase())), _ => repos.sort_by_key(|r| std::cmp::Reverse(r.updated_ms)), } // The hub's sky: the public repositories listed (a filter narrows it), // the most recently active first; never a private one, whoever looks. let public: Vec = repos.iter().filter(|r| r.visibility == "public").take(SKY_REPOS).cloned().collect(); let ids: Vec = public.iter().map(|r| r.id).collect(); let now = db::now_ms(); let sky = db::activity::sky(&app.db, &ids, now - SKY_DAYS * 86_400_000, SKY_EVENTS).await.map_err(db_err)?; let backdrop = sky_json(&public, &sky, now, "hub"); Ok(Page(HomePage { description: app.config.site.description.clone(), shell, repos, owner: None, q: q.q, sort, topic, stars, backdrop })) } /// A person's page (hub spec §6): avatar, names, bio, linked signing /// keys, organisations, repositories and what they did lately. #[derive(Template)] #[template(path = "profile.html")] pub struct ProfilePage { shell: Shell, account: db::accounts::Account, keys: Vec, orgs: Vec<(db::accounts::Organisation, String)>, repos: Vec, feed: Vec, is_me: bool, /// The year's pulse as JSON for the page's script (#217); `<` escaped. pulse: String, /// Changes pushed in the last year, shown beside the pulse. pulse_total: i64, } impl ProfilePage { /// The website as a link only when it is http(s). fn website_href(&self) -> Option<&str> { let w = self.account.website.trim(); (w.starts_with("https://") || w.starts_with("http://")).then_some(w) } } /// An organisation's page: description, members with their roles, teams, /// repositories and recent activity in them. #[derive(Template)] #[template(path = "org.html")] pub struct OrgPage { shell: Shell, org: db::accounts::Organisation, members: Vec<(db::accounts::Account, String)>, teams: Vec<(db::accounts::Team, usize)>, repos: Vec, feed: Vec, can_admin: bool, } const DAY_MS: i64 = 86_400_000; /// The profile's pulse (#217): changes this account pushed each day of the /// last year, in repositories the viewer may see, with the share pushed /// through agent tokens, per repository, and its releases to label peaks. /// `{"start": MS, "repos": [NAME], "days": [[TOTAL, VIA, [[REPO, N]..]]..], /// "peaks": [[DAY, LABEL]..], "months": M}`: the last 3, 6, 9 or 12 /// months, the shortest that holds their first push of the year, ending /// today (UTC). async fn pulse_json(app: &Shared, account: &db::accounts::Account, seen: &[db::repos::RepoRow]) -> sqlx::Result<(String, i64)> { let today = db::now_ms() / DAY_MS; let ids: Vec = seen.iter().map(|r| r.id).collect(); let rows = db::activity::pulse(&app.db, account.id, &ids, (today - 364) * DAY_MS).await?; // Snap to 3, 6, 9 or 12 months: no point drawing a year for a month's work. let used = rows.first().map(|r| today - r.1 / DAY_MS + 1).unwrap_or(0); let (months, span) = [(3, 91), (6, 182), (9, 273), (12, 365)].into_iter().find(|(_, d)| used <= *d).unwrap_or((12, 365)); let first = today - span + 1; let (pulse, total) = pulse_of(&rows, seen, first, span as usize); let mut value = pulse; let mut peaks = Vec::new(); for (repo_id, tag, at) in db::activity::releases_by(&app.db, account.id, &ids, first * DAY_MS).await? { let name = seen.iter().find(|r| r.id == repo_id).map(|r| r.name.as_str()).unwrap_or(""); peaks.push(serde_json::json!([at / DAY_MS - first, format!("{name} {tag}")])); } value["peaks"] = serde_json::Value::Array(peaks); value["months"] = months.into(); Ok((value.to_string().replace('<', "\\u003c"), total)) } /// The days of a pulse from push rows `(repo_id, at_ms, changes, via)`: /// repositories ordered by how much went to them, most first. fn pulse_of(rows: &[(i64, i64, i64, String)], seen: &[db::repos::RepoRow], first: i64, span: usize) -> (serde_json::Value, i64) { let mut per_repo: std::collections::BTreeMap = std::collections::BTreeMap::new(); for (repo, _, n, _) in rows { *per_repo.entry(*repo).or_default() += n; } let mut order: Vec<(i64, i64)> = per_repo.into_iter().collect(); order.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); let names: Vec = order .iter() .map(|(id, _)| seen.iter().find(|r| r.id == *id).map(|r| format!("{}/{}", r.owner, r.name)).unwrap_or_default()) .collect(); let slot = |id: i64| order.iter().position(|(r, _)| *r == id).unwrap_or(0); let mut days: Vec<(i64, i64, std::collections::BTreeMap)> = vec![(0, 0, Default::default()); span]; let mut total = 0; for (repo, at, n, via) in rows { let d = at / DAY_MS - first; if d < 0 || d as usize >= span { continue; } let day = &mut days[d as usize]; day.0 += n; if !via.is_empty() { day.1 += n; } *day.2.entry(slot(*repo)).or_default() += n; total += n; } let days: Vec = days .into_iter() .map(|(t, v, by)| serde_json::json!([t, v, by.into_iter().map(|(r, n)| serde_json::json!([r, n])).collect::>()])) .collect(); (serde_json::json!({ "start": first * DAY_MS, "repos": names, "days": days }), total) } /// How many activity lines an owner page shows. const OWNER_FEED: i64 = 20; /// `/{owner}`: a person's profile, an organisation's page, or (for an /// owner that is neither, such as repositories an administrator created /// for a name nobody holds) the list of its repositories. pub async fn owner(State(app): State, viewer: Viewer, Path(owner): Path) -> Result { let shell = Shell::new(&app, &format!("/{owner}"), &viewer); if !crate::repos::names::valid_owner(&owner) { return Err(Error::not_found(shell)); } let db_err = |e| Error::from_db(shell.clone(), e); let mut repos = visible(&app, &shell, &viewer, db::repos::list_by_owner(&app.db, &owner).await.map_err(db_err)?).await?; repos.sort_by_key(|r| std::cmp::Reverse(r.updated_ms)); if let Some(account) = db::accounts::account_by_name(&app.db, &owner).await.map_err(db_err)? { // What they did anywhere the viewer can see, not only in their own repositories. let all = visible(&app, &shell, &viewer, db::repos::list(&app.db).await.map_err(db_err)?).await?; let ids: Vec = all.iter().map(|r| r.id).collect(); let feed = db::activity::feed_by(&app.db, &ids, Some(&account.username), OWNER_FEED).await.map_err(db_err)?; let keys = db::accounts::identities_of(&app.db, account.id).await.map_err(db_err)?; let orgs = db::accounts::orgs_of(&app.db, account.id).await.map_err(db_err)?; let is_me = viewer.account().is_some_and(|a| a.id == account.id); let (pulse, pulse_total) = pulse_json(&app, &account, &all).await.map_err(db_err)?; return Ok(Page(ProfilePage { shell, account, keys, orgs, repos, feed, is_me, pulse, pulse_total }).into_response()); } if let Some(org) = db::accounts::org_by_name(&app.db, &owner).await.map_err(db_err)? { let members = db::accounts::org_members(&app.db, org.id).await.map_err(db_err)?; let mut teams = Vec::new(); for t in db::accounts::teams(&app.db, org.id).await.map_err(db_err)? { let n = db::accounts::team_members(&app.db, t.id).await.map_err(db_err)?.len(); teams.push((t, n)); } let ids: Vec = repos.iter().map(|r| r.id).collect(); let feed = db::activity::feed(&app.db, &ids, OWNER_FEED).await.map_err(db_err)?; let can_admin = viewer.is_admin() || viewer.account().is_some_and(|a| members.iter().any(|(m, role)| m.id == a.id && role == "owner")); return Ok(Page(OrgPage { shell, org, members, teams, repos, feed, can_admin }).into_response()); } if repos.is_empty() { return Err(Error::not_found(shell)); } let stars = db::repos::star_counts(&app.db).await.unwrap_or_default(); Ok(Page(HomePage { description: String::new(), shell, repos, owner: Some(owner), q: String::new(), sort: "activity".into(), topic: String::new(), stars, backdrop: String::new(), }) .into_response()) } /// The repositories among `rows` the viewer may see: public ones, internal /// ones when signed in, and those the viewer owns or collaborates on. pub(crate) async fn visible( app: &Shared, shell: &Shell, viewer: &Viewer, rows: Vec, ) -> Result, Error> { let mine: Vec = match viewer.account() { Some(a) if a.is_admin => rows.iter().map(|r| r.id).collect(), Some(a) => db::accounts::visible_repo_ids(&app.db, a.id).await.map_err(|e| Error::from_db(shell.clone(), e))?, None => vec![], }; let signed_in = viewer.signed_in(); Ok(rows .into_iter() .filter(|r| match r.visibility() { Visibility::Public => true, Visibility::Internal => signed_in, Visibility::Private => mine.contains(&r.id), }) .collect()) } pub async fn health(State(app): State) -> Response { let (states, weight, summaries) = app.repos.cache().stats(); let body = serde_json::json!({ "ok": true, "version": env!("CARGO_PKG_VERSION"), "build": env!("OXY_BUILD_ID"), "uptime_s": app.started.elapsed().as_secs(), // What the state cache holds, beside the process's size (#117). "state_cache": { "states": states, "estimated_mb": weight >> 20, "budget_mb": crate::repos::cache::budget() >> 20, "summaries": summaries, }, }); axum::Json(body).into_response() } /// The hub delegate identity object (hub spec §15.5), as `oxy identity /// export` writes one, so policy holders can admit it. pub async fn hub_key(State(app): State) -> Response { ( [(header::CONTENT_TYPE, "application/oxy-identity"), (header::CONTENT_DISPOSITION, "attachment; filename=\"hub.identity\"")], app.hub_key.bytes.clone(), ) .into_response() } /// The hub's TLS certificate (hub spec §15.5), for clients of a /// developer edition with a self-signed certificate: save it and point /// `oxy` at it with `OXY_CA` or `oxy config remote..ca`. pub async fn hub_ca(State(app): State) -> Response { if !app.config.tls.self_signed { return (axum::http::StatusCode::NOT_FOUND, "this hub does not use a self-signed certificate").into_response(); } match tokio::fs::read(app.config.tls_cert()).await { Ok(pem) => ( [(header::CONTENT_TYPE, "application/x-pem-file"), (header::CONTENT_DISPOSITION, "attachment; filename=\"oxyhub-ca.pem\"")], pem, ) .into_response(), Err(_) => (axum::http::StatusCode::NOT_FOUND, "no certificate").into_response(), } } /// What this hub is built from (hub spec §15): its version and build, and /// the versions of the Oxygen and OxygenFlow libraries it links. pub fn versions() -> serde_json::Value { serde_json::json!({ "oxygenhub": { "version": env!("CARGO_PKG_VERSION"), "build": env!("OXY_BUILD_ID") }, "oxy_core": oxy_core::VERSION, "flow_core": flow_core::VERSION, }) } #[derive(askama::Template)] #[template(path = "about.html")] pub struct AboutPage { shell: Shell, oxy_core: &'static str, flow_core: &'static str, /// Runners and the versions they report, for administrators. runners: Vec, } /// `GET /about`: the hub's version and what it is built on; runners too, for /// administrators. pub async fn about(State(app): State, viewer: Viewer) -> Result, Error> { let shell = Shell::new(&app, "/about", &viewer); let runners = if viewer.is_admin() { crate::db::runners::list(&app.db).await.unwrap_or_default() } else { vec![] }; Ok(Page(AboutPage { shell, oxy_core: oxy_core::VERSION, flow_core: flow_core::VERSION, runners })) }