OXYGENHUB
# 07b1a5bbf481 oxygenhub / crates / hub / src / web / home.rs
8.0 KiB · Rust · last changed Forks 1 month 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, State};
8
use axum::http::header;
9
use axum::response::{IntoResponse, Response};
10
use hub_api::Visibility;
11
12
#[derive(Template)]
13
#[template(path = "home.html")]
14
pub struct HomePage {
15
    shell: Shell,
16
    description: String,
17
    repos: Vec<db::repos::RepoRow>,
18
    owner: Option<String>,
19
}
20
21
/// The signed-in dashboard (hub spec §6): the review queue, the
22
/// viewer's own proposals and assigned issues, their repositories and
23
/// recent activity across everything they can see.
24
#[derive(Template)]
25
#[template(path = "dashboard.html")]
26
pub struct Dashboard {
27
    shell: Shell,
28
    mine: Vec<db::repos::RepoRow>,
29
    reviews: Vec<db::activity::ProposalLine>,
30
    authored: Vec<db::activity::ProposalLine>,
31
    assigned: Vec<db::activity::IssueLine>,
32
    feed: Vec<db::activity::Activity>,
33
}
34
35
/// How many activity lines the dashboard shows.
36
const FEED_LIMIT: i64 = 40;
37
38
/// `/`: the dashboard when signed in, every visible repository otherwise.
39
pub async fn index(State(app): State<Shared>, viewer: Viewer) -> Result<Response, Error> {
40
    let Some(account) = viewer.account().cloned() else {
41
        return Ok(explore(State(app), viewer).await?.into_response());
42
    };
43
    let shell = Shell::new(&app, "/", &viewer);
44
    let db_err = |e| Error::from_db(shell.clone(), e);
45
    let all = db::repos::list(&app.db).await.map_err(db_err)?;
46
    let member: Vec<i64> = db::accounts::visible_repo_ids(&app.db, account.id).await.map_err(db_err)?;
47
    let seen = visible(&app, &shell, &viewer, all).await?;
48
    let seen_ids: Vec<i64> = seen.iter().map(|r| r.id).collect();
49
    let mut mine: Vec<db::repos::RepoRow> = seen.into_iter().filter(|r| member.contains(&r.id)).collect();
50
    mine.sort_by_key(|r| std::cmp::Reverse(r.updated_ms));
51
    let reviews = db::activity::awaiting_review(&app.db, account.id, &member).await.map_err(db_err)?;
52
    let authored = db::activity::authored(&app.db, account.id, &seen_ids).await.map_err(db_err)?;
53
    let assigned = db::activity::assigned(&app.db, account.id, &seen_ids).await.map_err(db_err)?;
54
    let feed = db::activity::feed(&app.db, &seen_ids, FEED_LIMIT).await.map_err(db_err)?;
55
    Ok(Page(Dashboard { shell, mine, reviews, authored, assigned, feed }).into_response())
56
}
57
58
/// `/explore`: every repository the viewer may see.
59
pub async fn explore(State(app): State<Shared>, viewer: Viewer) -> Result<Page<HomePage>, Error> {
60
    let shell = Shell::new(&app, "/explore", &viewer);
61
    let repos = visible(&app, &shell, &viewer, db::repos::list(&app.db).await.map_err(|e| Error::from_db(shell.clone(), e))?).await?;
62
    Ok(Page(HomePage { description: app.config.site.description.clone(), shell, repos, owner: None }))
63
}
64
65
/// A person's page (hub spec §6): avatar, names, bio, linked signing
66
/// keys, organisations, repositories and what they did lately.
67
#[derive(Template)]
68
#[template(path = "profile.html")]
69
pub struct ProfilePage {
70
    shell: Shell,
71
    account: db::accounts::Account,
72
    keys: Vec<db::accounts::LinkedIdentity>,
73
    orgs: Vec<(db::accounts::Organisation, String)>,
74
    repos: Vec<db::repos::RepoRow>,
75
    feed: Vec<db::activity::Activity>,
76
    is_me: bool,
77
}
78
79
impl ProfilePage {
80
    /// The website as a link only when it is http(s).
81
    fn website_href(&self) -> Option<&str> {
82
        let w = self.account.website.trim();
83
        (w.starts_with("https://") || w.starts_with("http://")).then_some(w)
84
    }
85
}
86
87
/// An organisation's page: description, members with their roles, teams,
88
/// repositories and recent activity in them.
89
#[derive(Template)]
90
#[template(path = "org.html")]
91
pub struct OrgPage {
92
    shell: Shell,
93
    org: db::accounts::Organisation,
94
    members: Vec<(db::accounts::Account, String)>,
95
    teams: Vec<(db::accounts::Team, usize)>,
96
    repos: Vec<db::repos::RepoRow>,
97
    feed: Vec<db::activity::Activity>,
98
    can_admin: bool,
99
}
100
101
/// How many activity lines an owner page shows.
102
const OWNER_FEED: i64 = 20;
103
104
/// `/{owner}`: a person's profile, an organisation's page, or (for an
105
/// owner that is neither, such as repositories an administrator created
106
/// for a name nobody holds) the list of its repositories.
107
pub async fn owner(State(app): State<Shared>, viewer: Viewer, Path(owner): Path<String>) -> Result<Response, Error> {
108
    let shell = Shell::new(&app, &format!("/{owner}"), &viewer);
109
    if !crate::repos::names::valid_owner(&owner) {
110
        return Err(Error::not_found(shell));
111
    }
112
    let db_err = |e| Error::from_db(shell.clone(), e);
113
    let mut repos = visible(&app, &shell, &viewer, db::repos::list_by_owner(&app.db, &owner).await.map_err(db_err)?).await?;
114
    repos.sort_by_key(|r| std::cmp::Reverse(r.updated_ms));
115
    if let Some(account) = db::accounts::account_by_name(&app.db, &owner).await.map_err(db_err)? {
116
        // What they did anywhere the viewer can see, not only in their own repositories.
117
        let all = visible(&app, &shell, &viewer, db::repos::list(&app.db).await.map_err(db_err)?).await?;
118
        let ids: Vec<i64> = all.iter().map(|r| r.id).collect();
119
        let feed = db::activity::feed_by(&app.db, &ids, Some(&account.username), OWNER_FEED).await.map_err(db_err)?;
120
        let keys = db::accounts::identities_of(&app.db, account.id).await.map_err(db_err)?;
121
        let orgs = db::accounts::orgs_of(&app.db, account.id).await.map_err(db_err)?;
122
        let is_me = viewer.account().is_some_and(|a| a.id == account.id);
123
        return Ok(Page(ProfilePage { shell, account, keys, orgs, repos, feed, is_me }).into_response());
124
    }
125
    if let Some(org) = db::accounts::org_by_name(&app.db, &owner).await.map_err(db_err)? {
126
        let members = db::accounts::org_members(&app.db, org.id).await.map_err(db_err)?;
127
        let mut teams = Vec::new();
128
        for t in db::accounts::teams(&app.db, org.id).await.map_err(db_err)? {
129
            let n = db::accounts::team_members(&app.db, t.id).await.map_err(db_err)?.len();
130
            teams.push((t, n));
131
        }
132
        let ids: Vec<i64> = repos.iter().map(|r| r.id).collect();
133
        let feed = db::activity::feed(&app.db, &ids, OWNER_FEED).await.map_err(db_err)?;
134
        let can_admin =
135
            viewer.is_admin() || viewer.account().is_some_and(|a| members.iter().any(|(m, role)| m.id == a.id && role == "owner"));
136
        return Ok(Page(OrgPage { shell, org, members, teams, repos, feed, can_admin }).into_response());
137
    }
138
    if repos.is_empty() {
139
        return Err(Error::not_found(shell));
140
    }
141
    Ok(Page(HomePage { description: String::new(), shell, repos, owner: Some(owner) }).into_response())
142
}
143
144
/// The repositories among `rows` the viewer may see: public ones, internal
145
/// ones when signed in, and those the viewer owns or collaborates on.
146
pub(crate) async fn visible(
147
    app: &Shared,
148
    shell: &Shell,
149
    viewer: &Viewer,
150
    rows: Vec<db::repos::RepoRow>,
151
) -> Result<Vec<db::repos::RepoRow>, Error> {
152
    let mine: Vec<i64> = match viewer.account() {
153
        Some(a) if a.is_admin => rows.iter().map(|r| r.id).collect(),
154
        Some(a) => db::accounts::visible_repo_ids(&app.db, a.id).await.map_err(|e| Error::from_db(shell.clone(), e))?,
155
        None => vec![],
156
    };
157
    let signed_in = viewer.signed_in();
158
    Ok(rows
159
        .into_iter()
160
        .filter(|r| match r.visibility() {
161
            Visibility::Public => true,
162
            Visibility::Internal => signed_in,
163
            Visibility::Private => mine.contains(&r.id),
164
        })
165
        .collect())
166
}
167
168
pub async fn health(State(app): State<Shared>) -> Response {
169
    let body = serde_json::json!({
170
        "ok": true,
171
        "version": env!("CARGO_PKG_VERSION"),
172
        "uptime_s": app.started.elapsed().as_secs(),
173
    });
174
    axum::Json(body).into_response()
175
}
176
177
/// The hub delegate identity object (hub spec §15.5), as `oxy identity
178
/// export` writes one, so policy holders can admit it.
179
pub async fn hub_key(State(app): State<Shared>) -> Response {
180
    (
181
        [(header::CONTENT_TYPE, "application/oxy-identity"), (header::CONTENT_DISPOSITION, "attachment; filename=\"hub.identity\"")],
182
        app.hub_key.bytes.clone(),
183
    )
184
        .into_response()
185
}