| 1 |
|
| 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 |
|
| 24 |
kinds: Vec<(String, usize)>, |
| 25 |
repos: Vec<(String, usize)>, |
| 26 |
} |
| 27 |
|
| 28 |
|
| 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 |
|
| 43 |
pub fn with(&self, key: &str, value: &str) -> String { |
| 44 |
let mut q = self.clone(); |
| 45 |
match key { |
| 46 |
"view" => q.view = value.into(), |
| 47 |
"reason" => q.reason = value.into(), |
| 48 |
"kind" => q.kind = value.into(), |
| 49 |
"repo" => q.repo = value.into(), |
| 50 |
_ => {} |
| 51 |
} |
| 52 |
let parts: Vec<String> = [("view", &q.view), ("reason", &q.reason), ("kind", &q.kind), ("repo", &q.repo)] |
| 53 |
.iter() |
| 54 |
.filter(|(_, v)| !v.is_empty()) |
| 55 |
.map(|(k, v)| format!("{k}={}", super::settings::urlencode(v))) |
| 56 |
.collect(); |
| 57 |
if parts.is_empty() { |
| 58 |
"/notifications".into() |
| 59 |
} else { |
| 60 |
format!("/notifications?{}", parts.join("&")) |
| 61 |
} |
| 62 |
} |
| 63 |
|
| 64 |
|
| 65 |
pub fn here(&self) -> String { |
| 66 |
self.with("", "") |
| 67 |
} |
| 68 |
} |
| 69 |
|
| 70 |
|
| 71 |
fn family(kind: &str) -> &str { |
| 72 |
kind.split('.').next().unwrap_or(kind) |
| 73 |
} |
| 74 |
|
| 75 |
async fn inbox_page( |
| 76 |
app: &Shared, |
| 77 |
shell: Shell, |
| 78 |
me: &accounts::Account, |
| 79 |
q: InboxQuery, |
| 80 |
notice: Option<String>, |
| 81 |
) -> Result<Page<InboxPage>, Error> { |
| 82 |
let e = |e| Error::from_db(shell.clone(), e); |
| 83 |
let repo_id = match q.repo.split_once('/') { |
| 84 |
Some((o, n)) => db::repos::get(&app.db, o, n).await.map_err(e)?.map(|r| r.id).or(Some(-1)), |
| 85 |
None => None, |
| 86 |
}; |
| 87 |
let filter = ndb::Filter { |
| 88 |
done: q.view == "done", |
| 89 |
unread_only: q.view == "unread", |
| 90 |
reason: (!q.reason.is_empty()).then(|| q.reason.clone()), |
| 91 |
kind: (!q.kind.is_empty()).then(|| q.kind.clone()), |
| 92 |
repo_id, |
| 93 |
}; |
| 94 |
let threads = ndb::threads(&app.db, me.id, &filter, 200).await.map_err(e)?; |
| 95 |
|
| 96 |
let all = ndb::threads(&app.db, me.id, &ndb::Filter { done: filter.done, ..Default::default() }, 1000).await.map_err(e)?; |
| 97 |
let mut kinds: std::collections::BTreeMap<String, usize> = Default::default(); |
| 98 |
let mut repos: std::collections::BTreeMap<String, usize> = Default::default(); |
| 99 |
for t in &all { |
| 100 |
*kinds.entry(family(&t.kind).to_string()).or_default() += 1; |
| 101 |
if let Some(r) = &t.repo { |
| 102 |
*repos.entry(r.clone()).or_default() += 1; |
| 103 |
} |
| 104 |
} |
| 105 |
let unread = ndb::unread(&app.db, me.id).await.map_err(e)?; |
| 106 |
let fresh = accounts::account_by_id(&app.db, me.id).await.ok().flatten(); |
| 107 |
Ok(Page(InboxPage { |
| 108 |
shell, |
| 109 |
threads, |
| 110 |
unread, |
| 111 |
email_on: fresh.map(|a| a.email_notifications).unwrap_or(false), |
| 112 |
notice, |
| 113 |
q, |
| 114 |
kinds: kinds.into_iter().collect(), |
| 115 |
repos: repos.into_iter().collect(), |
| 116 |
})) |
| 117 |
} |
| 118 |
|
| 119 |
pub async fn inbox(State(app): State<Shared>, viewer: Viewer, Query(q): Query<InboxQuery>) -> Result<Page<InboxPage>, Error> { |
| 120 |
let shell = Shell::new(&app, "/notifications", &viewer); |
| 121 |
let me = require(&viewer, &shell)?; |
| 122 |
inbox_page(&app, shell, &me.account, q, None).await |
| 123 |
} |
| 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 |
|
| 133 |
pub async fn mark_read(State(app): State<Shared>, viewer: Viewer, Form(f): Form<ReadForm>) -> Result<Page<InboxPage>, Error> { |
| 134 |
let shell = Shell::new(&app, "/notifications", &viewer); |
| 135 |
let me = require(&viewer, &shell)?; |
| 136 |
csrf(&viewer, &f.csrf_token, &shell)?; |
| 137 |
ndb::mark_read(&app.db, me.account.id, f.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
| 138 |
inbox_page(&app, shell, &me.account, InboxQuery::default(), None).await |
| 139 |
} |
| 140 |
|
| 141 |
#[derive(Deserialize)] |
| 142 |
pub struct ThreadForm { |
| 143 |
#[serde(rename = "_csrf")] |
| 144 |
csrf_token: String, |
| 145 |
thread: String, |
| 146 |
|
| 147 |
action: String, |
| 148 |
|
| 149 |
#[serde(default)] |
| 150 |
back: String, |
| 151 |
} |
| 152 |
|
| 153 |
|
| 154 |
pub async fn thread(State(app): State<Shared>, viewer: Viewer, Form(f): Form<ThreadForm>) -> Result<Response, Error> { |
| 155 |
let shell = Shell::new(&app, "/notifications", &viewer); |
| 156 |
let me = require(&viewer, &shell)?; |
| 157 |
csrf(&viewer, &f.csrf_token, &shell)?; |
| 158 |
let (read, done) = match f.action.as_str() { |
| 159 |
"read" => (Some(true), None), |
| 160 |
"unread" => (Some(false), None), |
| 161 |
"done" => (None, Some(true)), |
| 162 |
"undone" => (None, Some(false)), |
| 163 |
_ => return Err(Error::bad(shell, "unknown action")), |
| 164 |
}; |
| 165 |
ndb::set_thread(&app.db, me.account.id, &f.thread, read, done).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
| 166 |
let back = if f.back.starts_with("/notifications") { f.back } else { "/notifications".into() }; |
| 167 |
Ok(Redirect::to(&back).into_response()) |
| 168 |
} |
| 169 |
|
| 170 |
|
| 171 |
pub async fn open(State(app): State<Shared>, viewer: Viewer, axum::extract::Path(id): axum::extract::Path<i64>) -> Result<Response, Error> { |
| 172 |
let shell = Shell::new(&app, "/notifications", &viewer); |
| 173 |
let me = require(&viewer, &shell)?; |
| 174 |
let n = ndb::get(&app.db, me.account.id, id) |
| 175 |
.await |
| 176 |
.map_err(|e| Error::from_db(shell.clone(), e))? |
| 177 |
.ok_or_else(|| Error::not_found(shell.clone()))?; |
| 178 |
ndb::set_thread(&app.db, me.account.id, &n.thread, Some(true), None).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
| 179 |
|
| 180 |
let to = if n.url.starts_with('/') && !n.url.starts_with("//") { n.url } else { "/notifications".into() }; |
| 181 |
Ok(Redirect::to(&to).into_response()) |
| 182 |
} |
| 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 |
|
| 192 |
pub async fn prefs(State(app): State<Shared>, viewer: Viewer, Form(f): Form<PrefsForm>) -> Result<Page<InboxPage>, Error> { |
| 193 |
let shell = Shell::new(&app, "/notifications", &viewer); |
| 194 |
let me = require(&viewer, &shell)?; |
| 195 |
csrf(&viewer, &f.csrf_token, &shell)?; |
| 196 |
let key = if me.account.unsubscribe_key.is_empty() { crate::auth::random_hex(16) } else { me.account.unsubscribe_key.clone() }; |
| 197 |
sqlx::query("UPDATE accounts SET email_notifications = ?, unsubscribe_key = ? WHERE id = ?") |
| 198 |
.bind(f.email.is_some()) |
| 199 |
.bind(&key) |
| 200 |
.bind(me.account.id) |
| 201 |
.execute(&app.db) |
| 202 |
.await |
| 203 |
.map_err(|e| Error::from_db(shell.clone(), e))?; |
| 204 |
inbox_page( |
| 205 |
&app, |
| 206 |
shell, |
| 207 |
&me.account, |
| 208 |
InboxQuery::default(), |
| 209 |
Some(if f.email.is_some() { "Email notifications are on.".into() } else { "Email notifications are off.".into() }), |
| 210 |
) |
| 211 |
.await |
| 212 |
} |
| 213 |
|
| 214 |
#[derive(Deserialize)] |
| 215 |
pub struct UnsubQuery { |
| 216 |
unsubscribe: Option<String>, |
| 217 |
} |
| 218 |
|
| 219 |
|
| 220 |
pub async fn unsubscribe(State(app): State<Shared>, Query(q): Query<UnsubQuery>) -> Response { |
| 221 |
if let Some(key) = q.unsubscribe.filter(|k| k.len() >= 16) { |
| 222 |
sqlx::query("UPDATE accounts SET email_notifications = 0 WHERE unsubscribe_key = ?").bind(key).execute(&app.db).await.ok(); |
| 223 |
return (axum::http::StatusCode::OK, "Email notifications are off for that account.").into_response(); |
| 224 |
} |
| 225 |
Redirect::to("/notifications").into_response() |
| 226 |
} |
| 227 |
|
| 228 |
|
| 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 |
|
| 254 |
pub async fn search(State(app): State<Shared>, viewer: Viewer, Query(q): Query<SearchQuery>) -> Result<Page<SearchPage>, Error> { |
| 255 |
let shell = Shell::new(&app, "/search", &viewer); |
| 256 |
let query = q.q.unwrap_or_default().trim().to_string(); |
| 257 |
if query.is_empty() { |
| 258 |
return Ok(Page(SearchPage { shell, q: query, repos: vec![], accounts: vec![], hits: vec![] })); |
| 259 |
} |
| 260 |
let e = |e| Error::from_db(shell.clone(), e); |
| 261 |
|
| 262 |
let all = db::repos::list(&app.db).await.map_err(e)?; |
| 263 |
let mut visible = Vec::new(); |
| 264 |
for r in all { |
| 265 |
let role = crate::auth::roles::role_for(&app.db, viewer.account(), &r).await.map_err(e)?; |
| 266 |
if crate::auth::roles::allows(role, crate::auth::Access::Read) { |
| 267 |
visible.push(r); |
| 268 |
} |
| 269 |
} |
| 270 |
let lower = query.to_lowercase(); |
| 271 |
let repos: Vec<db::repos::RepoRow> = visible |
| 272 |
.iter() |
| 273 |
.filter(|r| { |
| 274 |
r.name.to_lowercase().contains(&lower) |
| 275 |
|| r.description.to_lowercase().contains(&lower) |
| 276 |
|| r.owner.to_lowercase().contains(&lower) |
| 277 |
}) |
| 278 |
.cloned() |
| 279 |
.collect(); |
| 280 |
let accounts: Vec<String> = accounts::accounts(&app.db) |
| 281 |
.await |
| 282 |
.map_err(e)? |
| 283 |
.into_iter() |
| 284 |
.filter(|a| a.username.to_lowercase().contains(&lower) || a.display_name.to_lowercase().contains(&lower)) |
| 285 |
.map(|a| a.username) |
| 286 |
.collect(); |
| 287 |
let mut hits = Vec::new(); |
| 288 |
for h in ndb::search(&app.db, &query, None, 100).await.map_err(e)? { |
| 289 |
let Some(r) = visible.iter().find(|r| r.id == h.repo_id) else { continue }; |
| 290 |
let url = format!("/{}/{}/{}", r.slug(), if h.kind == "proposal" { "proposal" } else { "issue" }, h.number); |
| 291 |
hits.push(HitView { |
| 292 |
kind: h.kind, |
| 293 |
slug: r.slug(), |
| 294 |
number: h.number, |
| 295 |
title: h.title, |
| 296 |
snippet: crate::render::escape(&h.snippet).replace("<mark>", "<mark>").replace("</mark>", "</mark>"), |
| 297 |
url, |
| 298 |
}); |
| 299 |
} |
| 300 |
let _ = Visibility::Public; |
| 301 |
Ok(Page(SearchPage { shell, q: query, repos, accounts, hits })) |
| 302 |
} |