OXYGENHUB

Coverage / crates/hub/src/web/inbox.rs 93.8%

182 of 194 lines run, 28 of 70 functions · run #528 on main · run missed

1
//! The inbox, notification preferences and search (hub spec §10, §11).
2
3
use super::settings::{csrf, require};
4
use super::{filters, Error, Page, Shell};
5
use crate::auth::Viewer;
6
use crate::db::{self, accounts, notify as ndb};
7
use crate::Shared;
8
use askama::Template;
9
use axum::extract::{Form, Query, State};
10
use axum::response::{IntoResponse, Redirect, Response};
11
use hub_api::Visibility;
12
use serde::Deserialize;
13
14
#[derive(Template)]
15
#[template(path = "notifications.html")]
16
pub struct InboxPage {
17
    shell: Shell,
18
    threads: Vec<ndb::Thread>,
19
    unread: i64,
20
    email_on: bool,
21
    notice: Option<String>,
22
    q: InboxQuery,
23
    /// Kind families and repositories among the threads in the inbox, with counts.
24
    kinds: Vec<(String, usize)>,
25
    repos: Vec<(String, usize)>,
26
}
27
28
/// `?view=unread|done&reason=participating|watching&kind=issue&repo=owner/name`.
29
#[derive(Clone, Debug, Default, Deserialize)]
30
pub struct InboxQuery {
31
    #[serde(default)]
32
    pub view: String,
33
    #[serde(default)]
34
    pub reason: String,
35
    #[serde(default)]
36
    pub kind: String,
37
    #[serde(default)]
38
    pub repo: String,
39
}
40
41
impl InboxQuery {
42
    /// This query with one key changed, as a URL (empty values drop out).
43214×
    pub fn with(&self, key: &str, value: &str) -> String {
44214×
        let mut q = self.clone();
45214×
        match key {
46214×
            "view" => q.view = value.into(),
47163×
            "reason" => q.reason = value.into(),
48112×
            "kind" => q.kind = value.into(),
4966×
            "repo" => q.repo = value.into(),
5034×
            _ => {}
51
        }
52214×
        let parts: Vec<String> = [("view", &q.view), ("reason", &q.reason), ("kind", &q.kind), ("repo", &q.repo)]
53214×
            .iter()
54856×
            .filter(|(_, v)| !v.is_empty())
55214×
            .map(|(k, v)| format!("{k}={}", super::settings::urlencode(v)))
56214×
            .collect();
57214×
        if parts.is_empty() {
5874×
            "/notifications".into()
59
        } else {
60140×
            format!("/notifications?{}", parts.join("&"))
61
        }
62214×
    }
63
64
    /// This query as it is, for forms that come back to it.
6534×
    pub fn here(&self) -> String {
6634×
        self.with("", "")
6734×
    }
68
}
69
70
/// `issue.closed` → `issue`.
7142×
fn family(kind: &str) -> &str {
7242×
    kind.split('.').next().unwrap_or(kind)
7342×
}
74
7517×
async fn inbox_page(
7617×
    app: &Shared,
7717×
    shell: Shell,
7817×
    me: &accounts::Account,
7917×
    q: InboxQuery,
8017×
    notice: Option<String>,
8117×
) -> Result<Page<InboxPage>, Error> {
8217×
    let e = |e| Error::from_db(shell.clone(), e);
8317×
    let repo_id = match q.repo.split_once('/') {
842×
        Some((o, n)) => db::repos::get(&app.db, o, n).await.map_err(e)?.map(|r| r.id).or(Some(-1)),
8515×
        None => None,
86
    };
8717×
    let filter = ndb::Filter {
8817×
        done: q.view == "done",
8917×
        unread_only: q.view == "unread",
9017×
        reason: (!q.reason.is_empty()).then(|| q.reason.clone()),
9117×
        kind: (!q.kind.is_empty()).then(|| q.kind.clone()),
9217×
        repo_id,
93
    };
9417×
    let threads = ndb::threads(&app.db, me.id, &filter, 200).await.map_err(e)?;
95
    // Facets over the whole inbox, so the choices do not vanish as you pick.
9617×
    let all = ndb::threads(&app.db, me.id, &ndb::Filter { done: filter.done, ..Default::default() }, 1000).await.map_err(e)?;
9717×
    let mut kinds: std::collections::BTreeMap<String, usize> = Default::default();
9817×
    let mut repos: std::collections::BTreeMap<String, usize> = Default::default();
9942×
    for t in &all {
10042×
        *kinds.entry(family(&t.kind).to_string()).or_default() += 1;
10142×
        if let Some(r) = &t.repo {
10241×
            *repos.entry(r.clone()).or_default() += 1;
10341×
        }
104
    }
10517×
    let unread = ndb::unread(&app.db, me.id).await.map_err(e)?;
10617×
    let fresh = accounts::account_by_id(&app.db, me.id).await.ok().flatten();
107
    Ok(Page(InboxPage {
10817×
        shell,
10917×
        threads,
11017×
        unread,
11117×
        email_on: fresh.map(|a| a.email_notifications).unwrap_or(false),
11217×
        notice,
11317×
        q,
11417×
        kinds: kinds.into_iter().collect(),
11517×
        repos: repos.into_iter().collect(),
116
    }))
11717×
}
118
11915×
pub async fn inbox(State(app): State<Shared>, viewer: Viewer, Query(q): Query<InboxQuery>) -> Result<Page<InboxPage>, Error> {
12015×
    let shell = Shell::new(&app, "/notifications", &viewer);
12115×
    let me = require(&viewer, &shell)?;
12215×
    inbox_page(&app, shell, &me.account, q, None).await
12315×
}
124
125
#[derive(Deserialize)]
126
pub struct ReadForm {
127
    #[serde(rename = "_csrf")]
128
    csrf_token: String,
129
    #[serde(default)]
130
    id: Option<i64>,
131
}
132
1331×
pub async fn mark_read(State(app): State<Shared>, viewer: Viewer, Form(f): Form<ReadForm>) -> Result<Page<InboxPage>, Error> {
1341×
    let shell = Shell::new(&app, "/notifications", &viewer);
1351×
    let me = require(&viewer, &shell)?;
1361×
    csrf(&viewer, &f.csrf_token, &shell)?;
1371×
    ndb::mark_read(&app.db, me.account.id, f.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1381×
    inbox_page(&app, shell, &me.account, InboxQuery::default(), None).await
1391×
}
140
141
#[derive(Deserialize)]
142
pub struct ThreadForm {
143
    #[serde(rename = "_csrf")]
144
    csrf_token: String,
145
    thread: String,
146
    /// `read`, `unread`, `done` or `undone`.
147
    action: String,
148
    /// Where to go back to: the inbox with its filters.
149
    #[serde(default)]
150
    back: String,
151
}
152
153
/// `POST /notifications/thread`.
1542×
pub async fn thread(State(app): State<Shared>, viewer: Viewer, Form(f): Form<ThreadForm>) -> Result<Response, Error> {
1552×
    let shell = Shell::new(&app, "/notifications", &viewer);
1562×
    let me = require(&viewer, &shell)?;
1572×
    csrf(&viewer, &f.csrf_token, &shell)?;
1582×
    let (read, done) = match f.action.as_str() {
1592×
        "read" => (Some(true), None),
1601×
        "unread" => (Some(false), None),
1611×
        "done" => (None, Some(true)),
1620×
        "undone" => (None, Some(false)),
1630×
        _ => return Err(Error::bad(shell, "unknown action")),
164
    };
1652×
    ndb::set_thread(&app.db, me.account.id, &f.thread, read, done).await.map_err(|e| Error::from_db(shell.clone(), e))?;
1662×
    let back = if f.back.starts_with("/notifications") { f.back } else { "/notifications".into() };
1672×
    Ok(Redirect::to(&back).into_response())
1682×
}
169
170
/// `GET /notifications/open/{id}`: mark the thread read and go to it.
1712×
pub async fn open(State(app): State<Shared>, viewer: Viewer, axum::extract::Path(id): axum::extract::Path<i64>) -> Result<Response, Error> {
1722×
    let shell = Shell::new(&app, "/notifications", &viewer);
1732×
    let me = require(&viewer, &shell)?;
1742×
    let n = ndb::get(&app.db, me.account.id, id)
1752×
        .await
1762×
        .map_err(|e| Error::from_db(shell.clone(), e))?
1772×
        .ok_or_else(|| Error::not_found(shell.clone()))?;
1781×
    ndb::set_thread(&app.db, me.account.id, &n.thread, Some(true), None).await.map_err(|e| Error::from_db(shell.clone(), e))?;
179
    // Only local pages: a notification's URL is a path on this hub.
1801×
    let to = if n.url.starts_with('/') && !n.url.starts_with("//") { n.url } else { "/notifications".into() };
1811×
    Ok(Redirect::to(&to).into_response())
1822×
}
183
184
#[derive(Deserialize)]
185
pub struct PrefsForm {
186
    #[serde(rename = "_csrf")]
187
    csrf_token: String,
188
    #[serde(default)]
189
    email: Option<String>,
190
}
191
1921×
pub async fn prefs(State(app): State<Shared>, viewer: Viewer, Form(f): Form<PrefsForm>) -> Result<Page<InboxPage>, Error> {
1931×
    let shell = Shell::new(&app, "/notifications", &viewer);
1941×
    let me = require(&viewer, &shell)?;
1951×
    csrf(&viewer, &f.csrf_token, &shell)?;
1961×
    let key = if me.account.unsubscribe_key.is_empty() { crate::auth::random_hex(16) } else { me.account.unsubscribe_key.clone() };
1971×
    sqlx::query("UPDATE accounts SET email_notifications = $1, unsubscribe_key = $2 WHERE id = $3")
1981×
        .bind(f.email.is_some())
1991×
        .bind(&key)
2001×
        .bind(me.account.id)
2011×
        .execute(&app.db)
2021×
        .await
2031×
        .map_err(|e| Error::from_db(shell.clone(), e))?;
2041×
    inbox_page(
2051×
        &app,
2061×
        shell,
2071×
        &me.account,
2081×
        InboxQuery::default(),
2091×
        Some(if f.email.is_some() { "Email notifications are on.".into() } else { "Email notifications are off.".into() }),
210
    )
2111×
    .await
2121×
}
213
214
#[derive(Deserialize)]
215
pub struct UnsubQuery {
216
    unsubscribe: Option<String>,
217
}
218
219
/// The one-click link in every email: no session needed, the key is the proof.
2201×
pub async fn unsubscribe(State(app): State<Shared>, Query(q): Query<UnsubQuery>) -> Response {
2211×
    if let Some(key) = q.unsubscribe.filter(|k| k.len() >= 16) {
2221×
        sqlx::query("UPDATE accounts SET email_notifications = FALSE WHERE unsubscribe_key = $1").bind(key).execute(&app.db).await.ok();
2231×
        return (axum::http::StatusCode::OK, "Email notifications are off for that account.").into_response();
2240×
    }
2250×
    Redirect::to("/notifications").into_response()
2261×
}
227
228
// ------------------------------------------------------------------ search
229
230
pub struct HitView {
231
    pub kind: String,
232
    pub slug: String,
233
    pub number: i64,
234
    pub title: String,
235
    pub snippet: String,
236
    pub url: String,
237
}
238
239
#[derive(Template)]
240
#[template(path = "search.html")]
241
pub struct SearchPage {
242
    shell: Shell,
243
    q: String,
244
    repos: Vec<db::repos::RepoRow>,
245
    accounts: Vec<String>,
246
    hits: Vec<HitView>,
247
}
248
249
#[derive(Deserialize)]
250
pub struct SearchQuery {
251
    q: Option<String>,
252
}
253
2546×
pub async fn search(State(app): State<Shared>, viewer: Viewer, Query(q): Query<SearchQuery>) -> Result<Page<SearchPage>, Error> {
2556×
    let shell = Shell::new(&app, "/search", &viewer);
2566×
    let query = q.q.unwrap_or_default().trim().to_string();
2576×
    if query.is_empty() {
2580×
        return Ok(Page(SearchPage { shell, q: query, repos: vec![], accounts: vec![], hits: vec![] }));
2596×
    }
2606×
    let e = |e| Error::from_db(shell.clone(), e);
261
    // What the viewer may see.
2626×
    let all = db::repos::list(&app.db).await.map_err(e)?;
2636×
    let mut visible = Vec::new();
2646×
    for r in all {
2652×
        let role = crate::auth::roles::role_for(&app.db, viewer.account(), &r).await.map_err(e)?;
2662×
        if crate::auth::roles::allows(role, crate::auth::Access::Read) {
2672×
            visible.push(r);
2682×
        }
269
    }
2706×
    let lower = query.to_lowercase();
2716×
    let repos: Vec<db::repos::RepoRow> = visible
2726×
        .iter()
2736×
        .filter(|r| {
2742×
            r.name.to_lowercase().contains(&lower)
2751×
                || r.description.to_lowercase().contains(&lower)
2761×
                || r.owner.to_lowercase().contains(&lower)
2772×
        })
2786×
        .cloned()
2796×
        .collect();
2806×
    let accounts: Vec<String> = accounts::accounts(&app.db)
2816×
        .await
2826×
        .map_err(e)?
2836×
        .into_iter()
28412×
        .filter(|a| a.username.to_lowercase().contains(&lower) || a.display_name.to_lowercase().contains(&lower))
2856×
        .map(|a| a.username)
2866×
        .collect();
2876×
    let mut hits = Vec::new();
2886×
    for h in ndb::search(&app.db, &query, None, 100).await.map_err(e)? {
2891×
        let Some(r) = visible.iter().find(|r| r.id == h.repo_id) else { continue };
2901×
        let url = format!("/{}/{}/{}", r.slug(), if h.kind == "proposal" { "proposal" } else { "issue" }, h.number);
2911×
        hits.push(HitView {
2921×
            kind: h.kind,
2931×
            slug: r.slug(),
2941×
            number: h.number,
2951×
            title: h.title,
2961×
            snippet: crate::render::escape(&h.snippet).replace("&lt;mark&gt;", "<mark>").replace("&lt;/mark&gt;", "</mark>"),
2971×
            url,
2981×
        });
299
    }
3006×
    let _ = Visibility::Public;
3016×
    Ok(Page(SearchPage { shell, q: query, repos, accounts, hits }))
3026×
}