Skip to main content

codoseo_web/routes/
settings_alerts.rs

1//! `/settings/alerts`: where alerts go (the account's channels) and which changes go there at
2//! once (the per-site rules grid). Everything not marked instant waits for the Monday digest.
3
4use std::collections::HashSet;
5use std::time::Duration;
6
7use askama::Template;
8use axum::extract::{Path, State};
9use axum::http::{HeaderName, StatusCode, header};
10use axum::response::{Html, IntoResponse, Redirect, Response};
11use axum::routing::{get, post};
12use axum::{Form, Router};
13use codoseo_core::change::ChangeKind;
14use codoseo_core::plan::PlanLimits;
15use codoseo_notify::{AlertMessage, ChannelKind, ChannelTarget, DeliveryError, deliver};
16use codoseo_store::alert_rules::{self, ALL_KINDS};
17use codoseo_store::channels::{self, ChannelSummary, DeleteOutcome};
18use codoseo_store::events::{self, EventKind};
19use serde::Deserialize;
20use uuid::Uuid;
21
22use crate::auth::CurrentUser;
23use crate::auth::email;
24use crate::config::Mode;
25use crate::error::AppError;
26use crate::layout::{Screen, Shell};
27use crate::render::{Hx, ToastKind, html, toast};
28use crate::state::AppState;
29
30/// How long "Send test" waits for the channel.
31const TEST_TIMEOUT: Duration = Duration::from_secs(10);
32/// "Send test" messages per account per hour.
33const TEST_LIMIT: i64 = 5;
34
35pub fn routes() -> Router<AppState> {
36    Router::new()
37        .route("/settings/alerts", get(page))
38        .route("/settings/alerts/channels", post(add_channel))
39        .route(
40            "/settings/alerts/channels/{id}/delete",
41            post(delete_channel),
42        )
43        .route("/settings/alerts/channels/{id}/mute", post(mute_channel))
44        .route(
45            "/settings/alerts/channels/{id}/enable",
46            post(enable_channel),
47        )
48        .route("/settings/alerts/channels/{id}/test", post(test_channel))
49        .route("/settings/alerts/rules", post(set_rule))
50}
51
52const UPGRADE: &str = "Slack, Discord and webhook alerts are on Pro and Agency. \
53                       Upgrade to Pro to use them.";
54
55fn kind_label(kind: ChannelKind) -> &'static str {
56    match kind {
57        ChannelKind::Email => "Email",
58        ChannelKind::Slack => "Slack",
59        ChannelKind::Discord => "Discord",
60        ChannelKind::Webhook => "Webhook",
61    }
62}
63
64/// Turns on the default instant rules on a new site for every enabled channel of the account,
65/// the account's own email address first. The alert planner covers any site this misses, so a
66/// failure is logged and not shown.
67pub async fn default_rules_for_site(state: &AppState, account_id: Uuid, site_id: Uuid) {
68    let result = async {
69        channels::ensure_default_email(&state.pool, &state.channel_key, account_id)
70            .await
71            .map_err(|e| e.to_string())?;
72        alert_rules::create_defaults_for_site(&state.pool, account_id, site_id)
73            .await
74            .map_err(|e| e.to_string())
75    }
76    .await;
77    if let Err(error) = result {
78        tracing::warn!(%site_id, %error, "could not create the default alert rules");
79    }
80}
81
82// ---- views -----------------------------------------------------------------------------------
83
84pub struct ChannelView {
85    pub id: Uuid,
86    pub kind: &'static str,
87    pub name: String,
88    pub target: String,
89    /// `active`, `muted` or `off`
90    pub status: &'static str,
91    pub status_label: &'static str,
92    pub error: Option<String>,
93    pub muted: bool,
94    pub enabled: bool,
95    pub is_default: bool,
96}
97
98pub struct RuleCell {
99    pub channel: Uuid,
100    pub label: String,
101    pub checked: bool,
102}
103
104pub struct RuleRow {
105    pub slug: &'static str,
106    pub label: &'static str,
107    pub note: &'static str,
108    pub cells: Vec<RuleCell>,
109}
110
111pub struct SiteRules {
112    pub id: Uuid,
113    pub domain: String,
114    pub rows: Vec<RuleRow>,
115}
116
117pub struct ColumnView {
118    pub label: String,
119    pub off: bool,
120}
121
122pub struct NewSecret {
123    pub channel: String,
124    pub secret: String,
125}
126
127#[derive(Template)]
128#[template(path = "settings/alerts_form.html")]
129pub struct AddChannelForm {
130    pub kinds: Vec<(&'static str, &'static str)>,
131    pub kind: String,
132    pub target: String,
133    pub name: String,
134    pub error: Option<String>,
135    /// Show "Upgrade to Pro": Slack, Discord and webhooks aren't on this plan.
136    pub upgrade: bool,
137    /// Link the hint to `/billing` (cloud only; self-hosted has no billing pages).
138    pub upgrade_link: bool,
139}
140
141#[derive(Template)]
142#[template(path = "settings/alerts.html")]
143pub struct AlertsPage {
144    pub shell: Shell,
145    pub channels: Vec<ChannelView>,
146    pub form: AddChannelForm,
147    pub columns: Vec<ColumnView>,
148    pub sites: Vec<SiteRules>,
149    pub secret: Option<NewSecret>,
150}
151
152#[derive(Template)]
153#[template(path = "settings/alerts_test.html")]
154struct TestResult {
155    error: Option<String>,
156}
157
158fn channel_view(c: &ChannelSummary) -> ChannelView {
159    let (status, status_label) = if !c.enabled {
160        ("off", "Turned off")
161    } else if c.muted {
162        ("muted", "Muted")
163    } else {
164        ("active", "Active")
165    };
166    ChannelView {
167        id: c.id,
168        kind: kind_label(c.kind),
169        name: c
170            .name
171            .clone()
172            .unwrap_or_else(|| kind_label(c.kind).to_owned()),
173        target: c.target.clone(),
174        status,
175        status_label,
176        error: if c.enabled {
177            None
178        } else {
179            c.last_error.clone()
180        },
181        muted: c.muted,
182        enabled: c.enabled,
183        is_default: c.is_default,
184    }
185}
186
187fn add_form(state: &AppState, email_only: bool) -> AddChannelForm {
188    let mut kinds = vec![("email", "Email")];
189    if !email_only {
190        kinds.extend([
191            ("slack", "Slack"),
192            ("discord", "Discord"),
193            ("webhook", "Webhook"),
194        ]);
195    }
196    AddChannelForm {
197        kinds,
198        kind: "email".to_owned(),
199        target: String::new(),
200        name: String::new(),
201        error: None,
202        upgrade: email_only,
203        upgrade_link: email_only && state.config.mode == Mode::Cloud,
204    }
205}
206
207fn note(kind: ChangeKind) -> &'static str {
208    match kind {
209        ChangeKind::BecameNoindex => "key pages only",
210        ChangeKind::ErrorSpike => "a burst of new 4xx and 5xx pages",
211        ChangeKind::SitemapShrank => "lost 10% or more",
212        _ => "",
213    }
214}
215
216async fn render_page(
217    state: &AppState,
218    user: &CurrentUser,
219    form: AddChannelForm,
220    secret: Option<NewSecret>,
221) -> Result<Html<String>, AppError> {
222    let pool = &state.pool;
223    let sites = codoseo_store::sites::list_for_account(pool, user.id()).await?;
224    // The account's own address is always a channel; sites that predate alert rules get their
225    // default rules the first time this page is opened.
226    let default = channels::ensure_default_email(pool, &state.channel_key, user.id())
227        .await
228        .map_err(AppError::internal)?;
229    for site in &sites {
230        alert_rules::create_defaults(pool, site.id, default).await?;
231    }
232    let listed = channels::list_for_account(pool, &state.channel_key, user.id())
233        .await
234        .map_err(AppError::internal)?;
235    let on: HashSet<(Uuid, ChangeKind, Uuid)> = alert_rules::grid(pool, user.id())
236        .await?
237        .into_iter()
238        .filter(|c| c.instant)
239        .map(|c| (c.site_id, c.kind, c.channel_id))
240        .collect();
241
242    let column_label = |c: &ChannelSummary| match &c.name {
243        Some(name) => format!("{name} · {}", c.target),
244        None => format!("{} · {}", kind_label(c.kind), c.target),
245    };
246    let sites = sites
247        .iter()
248        .map(|site| SiteRules {
249            id: site.id,
250            domain: site.domain.clone(),
251            rows: ALL_KINDS
252                .iter()
253                .map(|&kind| RuleRow {
254                    slug: kind.slug(),
255                    label: kind.label(),
256                    note: note(kind),
257                    cells: listed
258                        .iter()
259                        .map(|c| RuleCell {
260                            channel: c.id,
261                            label: format!("{} to {}", kind.label(), column_label(c)),
262                            checked: on.contains(&(site.id, kind, c.id)),
263                        })
264                        .collect(),
265                })
266                .collect(),
267        })
268        .collect();
269    let shell = Shell::load(state, user, None, Screen::Alerts).await?;
270    html(&AlertsPage {
271        shell,
272        channels: listed.iter().map(channel_view).collect(),
273        form,
274        columns: listed
275            .iter()
276            .map(|c| ColumnView {
277                label: column_label(c),
278                off: !c.enabled,
279            })
280            .collect(),
281        sites,
282        secret,
283    })
284}
285
286fn email_only(user: &CurrentUser) -> bool {
287    PlanLimits::for_plan(user.account.plan).email_alerts_only
288}
289
290async fn page(State(state): State<AppState>, user: CurrentUser) -> Result<Response, AppError> {
291    let form = add_form(&state, email_only(&user));
292    Ok(render_page(&state, &user, form, None)
293        .await?
294        .into_response())
295}
296
297// ---- channels --------------------------------------------------------------------------------
298
299#[derive(Deserialize)]
300struct NewChannel {
301    kind: String,
302    #[serde(default)]
303    target: String,
304    #[serde(default)]
305    name: String,
306}
307
308/// A fresh signing secret for a webhook channel.
309fn new_secret() -> String {
310    let mut bytes = [0u8; 32];
311    rand::fill(&mut bytes);
312    let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
313    format!("whsec_{hex}")
314}
315
316/// Why a channel could not be added: shown inline for htmx, as an error page otherwise.
317enum Refusal {
318    Invalid(String),
319    Limit(String),
320}
321
322async fn add_channel(
323    State(state): State<AppState>,
324    user: CurrentUser,
325    hx: Hx,
326    Form(form): Form<NewChannel>,
327) -> Result<Response, AppError> {
328    let email_only = email_only(&user);
329    match create(&state, &user, &form, email_only).await? {
330        Ok(created) => {
331            if let Some(secret) = created.secret {
332                // Shown once, on the page this answers with.
333                let page = render_page(
334                    &state,
335                    &user,
336                    add_form(&state, email_only),
337                    Some(NewSecret {
338                        channel: created.name,
339                        secret,
340                    }),
341                )
342                .await?;
343                return Ok((
344                    [
345                        (
346                            HeaderName::from_static("hx-replace-url"),
347                            "/settings/alerts",
348                        ),
349                        // The secret is on this page; nothing may cache it.
350                        (header::CACHE_CONTROL, "no-store"),
351                    ],
352                    page,
353                )
354                    .into_response());
355            }
356            Ok(Redirect::to("/settings/alerts").into_response())
357        }
358        Err(refusal) => {
359            let (status, message) = match &refusal {
360                Refusal::Invalid(m) => (StatusCode::BAD_REQUEST, m.clone()),
361                Refusal::Limit(m) => (StatusCode::FORBIDDEN, m.clone()),
362            };
363            if !hx.request {
364                return Err(match refusal {
365                    Refusal::Invalid(m) => AppError::BadRequest(m),
366                    Refusal::Limit(m) => AppError::Limit(m),
367                });
368            }
369            let mut again = add_form(&state, email_only);
370            again.kind = form.kind.clone();
371            again.target = form.target.trim().to_owned();
372            again.name = form.name.trim().to_owned();
373            again.error = Some(message);
374            Ok((
375                status,
376                [
377                    (HeaderName::from_static("hx-retarget"), "#add-channel"),
378                    (HeaderName::from_static("hx-reswap"), "outerHTML"),
379                ],
380                html(&again)?,
381            )
382                .into_response())
383        }
384    }
385}
386
387struct Created {
388    name: String,
389    /// The webhook signing secret, to show once.
390    secret: Option<String>,
391}
392
393async fn create(
394    state: &AppState,
395    user: &CurrentUser,
396    form: &NewChannel,
397    email_only: bool,
398) -> Result<Result<Created, Refusal>, AppError> {
399    let Some(kind) = ChannelKind::parse(form.kind.trim()) else {
400        return Ok(Err(Refusal::Invalid(
401            "Pick where alerts should go.".to_owned(),
402        )));
403    };
404    if email_only && kind != ChannelKind::Email {
405        return Ok(Err(Refusal::Limit(UPGRADE.to_owned())));
406    }
407    let held = channels::count_for_account(&state.pool, user.id())
408        .await
409        .map_err(AppError::internal)?;
410    if held >= channels::MAX_CHANNELS_PER_ACCOUNT {
411        return Ok(Err(Refusal::Limit(format!(
412            "You can have up to {} alert channels. Delete one you don't use to add another.",
413            channels::MAX_CHANNELS_PER_ACCOUNT
414        ))));
415    }
416    let raw = form.target.trim();
417    let mut secret = None;
418    let target = match kind {
419        ChannelKind::Email => match email::parse(raw) {
420            Some(address) => ChannelTarget::Email {
421                to: address.to_owned(),
422            },
423            None => {
424                return Ok(Err(Refusal::Invalid(
425                    "Enter a valid email address.".to_owned(),
426                )));
427            }
428        },
429        _ => {
430            let url = match state.notify_http.validate_target(kind, raw).await {
431                Ok(url) => url,
432                Err(e) => return Ok(Err(Refusal::Invalid(sentence(&e.to_string())))),
433            };
434            match kind {
435                ChannelKind::Slack => ChannelTarget::Slack { url },
436                ChannelKind::Discord => ChannelTarget::Discord { url },
437                _ => {
438                    let s = new_secret();
439                    secret = Some(s.clone());
440                    ChannelTarget::Webhook { url, secret: s }
441                }
442            }
443        }
444    };
445    let name = form.name.trim();
446    let name = (!name.is_empty()).then(|| name.chars().take(60).collect::<String>());
447    let id = channels::create(
448        &state.pool,
449        &state.channel_key,
450        user.id(),
451        &target,
452        name.as_deref(),
453        false,
454    )
455    .await
456    .map_err(AppError::internal)?;
457    alert_rules::create_defaults_for_account(&state.pool, user.id(), id).await?;
458    Ok(Ok(Created {
459        name: name.unwrap_or_else(|| kind_label(kind).to_owned()),
460        secret,
461    }))
462}
463
464/// "that doesn't look like a web address" -> "That doesn't look like a web address."
465fn sentence(s: &str) -> String {
466    let mut chars = s.chars();
467    let mut out: String = match chars.next() {
468        Some(first) => first.to_uppercase().chain(chars).collect(),
469        None => return String::new(),
470    };
471    if !out.ends_with('.') {
472        out.push('.');
473    }
474    out
475}
476
477/// The account's channel, or a 404.
478async fn owned(
479    state: &AppState,
480    user: &CurrentUser,
481    id: Uuid,
482) -> Result<channels::ChannelState, AppError> {
483    match channels::state(&state.pool, id)
484        .await
485        .map_err(AppError::internal)?
486    {
487        Some(c) if c.account_id == user.id() => Ok(c),
488        _ => Err(AppError::NotFound),
489    }
490}
491
492async fn delete_channel(
493    State(state): State<AppState>,
494    user: CurrentUser,
495    Path(id): Path<Uuid>,
496) -> Result<Response, AppError> {
497    match channels::delete(&state.pool, user.id(), id).await? {
498        DeleteOutcome::Deleted => Ok(Redirect::to("/settings/alerts").into_response()),
499        DeleteOutcome::NotFound => Err(AppError::NotFound),
500        DeleteOutcome::DefaultChannel => Err(AppError::Conflict(
501            "Your own email address is always a channel. Mute it instead of deleting it."
502                .to_owned(),
503        )),
504    }
505}
506
507#[derive(Deserialize)]
508struct MuteForm {
509    muted: String,
510}
511
512async fn mute_channel(
513    State(state): State<AppState>,
514    user: CurrentUser,
515    Path(id): Path<Uuid>,
516    Form(form): Form<MuteForm>,
517) -> Result<Response, AppError> {
518    if !channels::set_muted(&state.pool, user.id(), id, form.muted == "true").await? {
519        return Err(AppError::NotFound);
520    }
521    Ok(Redirect::to("/settings/alerts").into_response())
522}
523
524async fn enable_channel(
525    State(state): State<AppState>,
526    user: CurrentUser,
527    Path(id): Path<Uuid>,
528) -> Result<Response, AppError> {
529    if !channels::reenable(&state.pool, user.id(), id).await? {
530        return Err(AppError::NotFound);
531    }
532    Ok(Redirect::to("/settings/alerts").into_response())
533}
534
535/// Sends a test message to the channel, waiting up to 10 seconds, and answers with the small
536/// fragment that replaces the button's result slot.
537async fn test_channel(
538    State(state): State<AppState>,
539    user: CurrentUser,
540    Path(id): Path<Uuid>,
541) -> Result<Response, AppError> {
542    owned(&state, &user, id).await?;
543    if let Err(minutes) = events::take_allowance(
544        &state.pool,
545        EventKind::ChannelTest,
546        user.id(),
547        TEST_LIMIT,
548        60,
549    )
550    .await?
551    {
552        let message = format!(
553            "Too many test messages — try again in {minutes} minute{}.",
554            if minutes == 1 { "" } else { "s" }
555        );
556        let page = html(&TestResult {
557            error: Some(message),
558        })?;
559        return Ok((StatusCode::TOO_MANY_REQUESTS, page).into_response());
560    }
561    let result =
562        |error: Option<String>| html(&TestResult { error }).map(IntoResponse::into_response);
563    let target = match channels::get_target(&state.pool, &state.channel_key, id).await {
564        Ok(Some(target)) => target,
565        Ok(None) => return Err(AppError::NotFound),
566        Err(e) => return result(Some(format!("The saved address can't be read: {e}"))),
567    };
568    let sites = codoseo_store::sites::list_for_account(&state.pool, user.id()).await?;
569    let (domain, dashboard) = match sites.first() {
570        Some(site) => (
571            site.domain.clone(),
572            state
573                .config
574                .base_url
575                .join(&format!("s/{}/changes", site.id)),
576        ),
577        None => ("your site".to_owned(), Ok(state.config.base_url.clone())),
578    };
579    let dashboard = dashboard.map_err(AppError::internal)?;
580    let message = AlertMessage::test(domain, dashboard);
581    let outcome = tokio::time::timeout(
582        TEST_TIMEOUT,
583        deliver(&state.notify_http, &state.mailer, &target, &message),
584    )
585    .await;
586    match outcome {
587        Ok(Ok(())) => result(None),
588        // Mail errors name our own SMTP host and its replies; they belong in the log, not on the
589        // page. A webhook's status or refusal is about the user's own endpoint, so it shows.
590        Ok(Err(DeliveryError::Mail(e))) => {
591            tracing::warn!(channel_id = %id, error = %e, "test email failed");
592            result(Some("Couldn't send the test message.".to_owned()))
593        }
594        Ok(Err(e)) => result(Some(sentence(&e.to_string()))),
595        Err(_) => result(Some("It didn't answer within 10 seconds.".to_owned())),
596    }
597}
598
599// ---- rules -----------------------------------------------------------------------------------
600
601#[derive(Deserialize)]
602struct RuleForm {
603    site: Uuid,
604    kind: String,
605    channel: Uuid,
606    /// A checked box sends a value; an unchecked one sends nothing.
607    #[serde(default)]
608    instant: Option<String>,
609}
610
611async fn set_rule(
612    State(state): State<AppState>,
613    user: CurrentUser,
614    hx: Hx,
615    Form(form): Form<RuleForm>,
616) -> Result<Response, AppError> {
617    let kind: ChangeKind = ALL_KINDS
618        .iter()
619        .copied()
620        .find(|k| k.slug() == form.kind)
621        .ok_or_else(|| AppError::BadRequest("That kind of change doesn't exist.".to_owned()))?;
622    // Both must be the account's own.
623    if codoseo_store::sites::get_for_account(&state.pool, user.id(), form.site)
624        .await?
625        .is_none()
626    {
627        return Err(AppError::NotFound);
628    }
629    owned(&state, &user, form.channel).await?;
630    let instant = form
631        .instant
632        .as_deref()
633        .is_some_and(|v| v != "false" && v != "0");
634    if !alert_rules::set(&state.pool, form.site, kind, form.channel, instant).await? {
635        return Err(AppError::NotFound);
636    }
637    if hx.request {
638        let message = if instant {
639            "Instant alerts on"
640        } else {
641            "Moved to the weekly digest"
642        };
643        return Ok((StatusCode::NO_CONTENT, [toast(ToastKind::Ok, message)]).into_response());
644    }
645    Ok(Redirect::to("/settings/alerts").into_response())
646}