OXYGENHUB
# fd3db644df22 oxygenhub / crates / hub / src / web / inbox.rs
6.1 KiB · Rust · last changed Milestone 0.4: issues and notifications 1 month ago source blame history raw
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
    rows: Vec<ndb::Notification>,
19
    unread: i64,
20
    email_on: bool,
21
    notice: Option<String>,
22
}
23
24
async fn inbox_page(app: &Shared, shell: Shell, me: &accounts::Account, notice: Option<String>) -> Result<Page<InboxPage>, Error> {
25
    let rows = ndb::inbox(&app.db, me.id, 200).await.map_err(|e| Error::from_db(shell.clone(), e))?;
26
    let unread = ndb::unread(&app.db, me.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
27
    let fresh = accounts::account_by_id(&app.db, me.id).await.ok().flatten();
28
    Ok(Page(InboxPage { shell, rows, unread, email_on: fresh.map(|a| a.email_notifications).unwrap_or(false), notice }))
29
}
30
31
pub async fn inbox(State(app): State<Shared>, viewer: Viewer) -> Result<Page<InboxPage>, Error> {
32
    let shell = Shell::new(&app, "/notifications", &viewer);
33
    let me = require(&viewer, &shell)?;
34
    inbox_page(&app, shell, &me.account, None).await
35
}
36
37
#[derive(Deserialize)]
38
pub struct ReadForm {
39
    #[serde(rename = "_csrf")]
40
    csrf_token: String,
41
    #[serde(default)]
42
    id: Option<i64>,
43
}
44
45
pub async fn mark_read(State(app): State<Shared>, viewer: Viewer, Form(f): Form<ReadForm>) -> Result<Page<InboxPage>, Error> {
46
    let shell = Shell::new(&app, "/notifications", &viewer);
47
    let me = require(&viewer, &shell)?;
48
    csrf(&viewer, &f.csrf_token, &shell)?;
49
    ndb::mark_read(&app.db, me.account.id, f.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
50
    inbox_page(&app, shell, &me.account, None).await
51
}
52
53
#[derive(Deserialize)]
54
pub struct PrefsForm {
55
    #[serde(rename = "_csrf")]
56
    csrf_token: String,
57
    #[serde(default)]
58
    email: Option<String>,
59
}
60
61
pub async fn prefs(State(app): State<Shared>, viewer: Viewer, Form(f): Form<PrefsForm>) -> Result<Page<InboxPage>, Error> {
62
    let shell = Shell::new(&app, "/notifications", &viewer);
63
    let me = require(&viewer, &shell)?;
64
    csrf(&viewer, &f.csrf_token, &shell)?;
65
    let key = if me.account.unsubscribe_key.is_empty() { crate::auth::random_hex(16) } else { me.account.unsubscribe_key.clone() };
66
    sqlx::query("UPDATE accounts SET email_notifications = ?, unsubscribe_key = ? WHERE id = ?")
67
        .bind(f.email.is_some())
68
        .bind(&key)
69
        .bind(me.account.id)
70
        .execute(&app.db)
71
        .await
72
        .map_err(|e| Error::from_db(shell.clone(), e))?;
73
    inbox_page(
74
        &app,
75
        shell,
76
        &me.account,
77
        Some(if f.email.is_some() { "Email notifications are on.".into() } else { "Email notifications are off.".into() }),
78
    )
79
    .await
80
}
81
82
#[derive(Deserialize)]
83
pub struct UnsubQuery {
84
    unsubscribe: Option<String>,
85
}
86
87
/// The one-click link in every email: no session needed, the key is the proof.
88
pub async fn unsubscribe(State(app): State<Shared>, Query(q): Query<UnsubQuery>) -> Response {
89
    if let Some(key) = q.unsubscribe.filter(|k| k.len() >= 16) {
90
        sqlx::query("UPDATE accounts SET email_notifications = 0 WHERE unsubscribe_key = ?").bind(key).execute(&app.db).await.ok();
91
        return (axum::http::StatusCode::OK, "Email notifications are off for that account.").into_response();
92
    }
93
    Redirect::to("/notifications").into_response()
94
}
95
96
// ------------------------------------------------------------------ search
97
98
pub struct HitView {
99
    pub kind: String,
100
    pub slug: String,
101
    pub number: i64,
102
    pub title: String,
103
    pub snippet: String,
104
    pub url: String,
105
}
106
107
#[derive(Template)]
108
#[template(path = "search.html")]
109
pub struct SearchPage {
110
    shell: Shell,
111
    q: String,
112
    repos: Vec<db::repos::RepoRow>,
113
    accounts: Vec<String>,
114
    hits: Vec<HitView>,
115
}
116
117
#[derive(Deserialize)]
118
pub struct SearchQuery {
119
    q: Option<String>,
120
}
121
122
pub async fn search(State(app): State<Shared>, viewer: Viewer, Query(q): Query<SearchQuery>) -> Result<Page<SearchPage>, Error> {
123
    let shell = Shell::new(&app, "/search", &viewer);
124
    let query = q.q.unwrap_or_default().trim().to_string();
125
    if query.is_empty() {
126
        return Ok(Page(SearchPage { shell, q: query, repos: vec![], accounts: vec![], hits: vec![] }));
127
    }
128
    let e = |e| Error::from_db(shell.clone(), e);
129
    // What the viewer may see.
130
    let all = db::repos::list(&app.db).await.map_err(e)?;
131
    let mut visible = Vec::new();
132
    for r in all {
133
        let role = crate::auth::roles::role_for(&app.db, viewer.account(), &r).await.map_err(e)?;
134
        if crate::auth::roles::allows(role, crate::auth::Access::Read) {
135
            visible.push(r);
136
        }
137
    }
138
    let lower = query.to_lowercase();
139
    let repos: Vec<db::repos::RepoRow> = visible
140
        .iter()
141
        .filter(|r| {
142
            r.name.to_lowercase().contains(&lower)
143
                || r.description.to_lowercase().contains(&lower)
144
                || r.owner.to_lowercase().contains(&lower)
145
        })
146
        .cloned()
147
        .collect();
148
    let accounts: Vec<String> = accounts::accounts(&app.db)
149
        .await
150
        .map_err(e)?
151
        .into_iter()
152
        .filter(|a| a.username.to_lowercase().contains(&lower) || a.display_name.to_lowercase().contains(&lower))
153
        .map(|a| a.username)
154
        .collect();
155
    let mut hits = Vec::new();
156
    for h in ndb::search(&app.db, &query, None, 100).await.map_err(e)? {
157
        let Some(r) = visible.iter().find(|r| r.id == h.repo_id) else { continue };
158
        let url = format!("/{}/{}/{}", r.slug(), if h.kind == "proposal" { "proposal" } else { "issue" }, h.number);
159
        hits.push(HitView {
160
            kind: h.kind,
161
            slug: r.slug(),
162
            number: h.number,
163
            title: h.title,
164
            snippet: crate::render::escape(&h.snippet).replace("&lt;mark&gt;", "<mark>").replace("&lt;/mark&gt;", "</mark>"),
165
            url,
166
        });
167
    }
168
    let _ = Visibility::Public;
169
    Ok(Page(SearchPage { shell, q: query, repos, accounts, hits }))
170
}