| 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 |
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 |
|
| 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 |
|
| 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 |
|
| 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("<mark>", "<mark>").replace("</mark>", "</mark>"), |
| 165 |
url, |
| 166 |
}); |
| 167 |
} |
| 168 |
let _ = Visibility::Public; |
| 169 |
Ok(Page(SearchPage { shell, q: query, repos, accounts, hits })) |
| 170 |
} |