//! The inbox, notification preferences and search (hub spec §10, §11). use super::settings::{csrf, require}; use super::{filters, Error, Page, Shell}; use crate::auth::Viewer; use crate::db::{self, accounts, notify as ndb}; use crate::Shared; use askama::Template; use axum::extract::{Form, Query, State}; use axum::response::{IntoResponse, Redirect, Response}; use hub_api::Visibility; use serde::Deserialize; #[derive(Template)] #[template(path = "notifications.html")] pub struct InboxPage { shell: Shell, rows: Vec, unread: i64, email_on: bool, notice: Option, } async fn inbox_page(app: &Shared, shell: Shell, me: &accounts::Account, notice: Option) -> Result, Error> { let rows = ndb::inbox(&app.db, me.id, 200).await.map_err(|e| Error::from_db(shell.clone(), e))?; let unread = ndb::unread(&app.db, me.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; let fresh = accounts::account_by_id(&app.db, me.id).await.ok().flatten(); Ok(Page(InboxPage { shell, rows, unread, email_on: fresh.map(|a| a.email_notifications).unwrap_or(false), notice })) } pub async fn inbox(State(app): State, viewer: Viewer) -> Result, Error> { let shell = Shell::new(&app, "/notifications", &viewer); let me = require(&viewer, &shell)?; inbox_page(&app, shell, &me.account, None).await } #[derive(Deserialize)] pub struct ReadForm { #[serde(rename = "_csrf")] csrf_token: String, #[serde(default)] id: Option, } pub async fn mark_read(State(app): State, viewer: Viewer, Form(f): Form) -> Result, Error> { let shell = Shell::new(&app, "/notifications", &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; ndb::mark_read(&app.db, me.account.id, f.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; inbox_page(&app, shell, &me.account, None).await } #[derive(Deserialize)] pub struct PrefsForm { #[serde(rename = "_csrf")] csrf_token: String, #[serde(default)] email: Option, } pub async fn prefs(State(app): State, viewer: Viewer, Form(f): Form) -> Result, Error> { let shell = Shell::new(&app, "/notifications", &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let key = if me.account.unsubscribe_key.is_empty() { crate::auth::random_hex(16) } else { me.account.unsubscribe_key.clone() }; sqlx::query("UPDATE accounts SET email_notifications = ?, unsubscribe_key = ? WHERE id = ?") .bind(f.email.is_some()) .bind(&key) .bind(me.account.id) .execute(&app.db) .await .map_err(|e| Error::from_db(shell.clone(), e))?; inbox_page( &app, shell, &me.account, Some(if f.email.is_some() { "Email notifications are on.".into() } else { "Email notifications are off.".into() }), ) .await } #[derive(Deserialize)] pub struct UnsubQuery { unsubscribe: Option, } /// The one-click link in every email: no session needed, the key is the proof. pub async fn unsubscribe(State(app): State, Query(q): Query) -> Response { if let Some(key) = q.unsubscribe.filter(|k| k.len() >= 16) { sqlx::query("UPDATE accounts SET email_notifications = 0 WHERE unsubscribe_key = ?").bind(key).execute(&app.db).await.ok(); return (axum::http::StatusCode::OK, "Email notifications are off for that account.").into_response(); } Redirect::to("/notifications").into_response() } // ------------------------------------------------------------------ search pub struct HitView { pub kind: String, pub slug: String, pub number: i64, pub title: String, pub snippet: String, pub url: String, } #[derive(Template)] #[template(path = "search.html")] pub struct SearchPage { shell: Shell, q: String, repos: Vec, accounts: Vec, hits: Vec, } #[derive(Deserialize)] pub struct SearchQuery { q: Option, } pub async fn search(State(app): State, viewer: Viewer, Query(q): Query) -> Result, Error> { let shell = Shell::new(&app, "/search", &viewer); let query = q.q.unwrap_or_default().trim().to_string(); if query.is_empty() { return Ok(Page(SearchPage { shell, q: query, repos: vec![], accounts: vec![], hits: vec![] })); } let e = |e| Error::from_db(shell.clone(), e); // What the viewer may see. let all = db::repos::list(&app.db).await.map_err(e)?; let mut visible = Vec::new(); for r in all { let role = crate::auth::roles::role_for(&app.db, viewer.account(), &r).await.map_err(e)?; if crate::auth::roles::allows(role, crate::auth::Access::Read) { visible.push(r); } } let lower = query.to_lowercase(); let repos: Vec = visible .iter() .filter(|r| { r.name.to_lowercase().contains(&lower) || r.description.to_lowercase().contains(&lower) || r.owner.to_lowercase().contains(&lower) }) .cloned() .collect(); let accounts: Vec = accounts::accounts(&app.db) .await .map_err(e)? .into_iter() .filter(|a| a.username.to_lowercase().contains(&lower) || a.display_name.to_lowercase().contains(&lower)) .map(|a| a.username) .collect(); let mut hits = Vec::new(); for h in ndb::search(&app.db, &query, None, 100).await.map_err(e)? { let Some(r) = visible.iter().find(|r| r.id == h.repo_id) else { continue }; let url = format!("/{}/{}/{}", r.slug(), if h.kind == "proposal" { "proposal" } else { "issue" }, h.number); hits.push(HitView { kind: h.kind, slug: r.slug(), number: h.number, title: h.title, snippet: crate::render::escape(&h.snippet).replace("<mark>", "").replace("</mark>", ""), url, }); } let _ = Visibility::Public; Ok(Page(SearchPage { shell, q: query, repos, accounts, hits })) }