ocre-cli 0.2.0

Command-line tool for Ocre: create, generate, migrate, run and deploy apps.
//! Email confirmation (HTML). Generated by `ocre g auth`.
//!
//! Sign-up emails a link valid for a day; opening it (and pressing the
//! button) sets `users.confirmed_at`. Unconfirmed users can sign in; pages
//! that need a confirmed address take `ConfirmedUser` instead of
//! `CurrentUser`. Signing in with a magic link or an OAuth provider also
//! confirms the address.

use askama::Template;
use axum::{
    Router,
    extract::{Path, State},
    http::{HeaderMap, Uri},
    response::{Html, Redirect},
    routing::{get, post},
};
use ocre::{
    Ctx, Result, Session,
    mail::{self, Email},
    render,
};

use crate::{
    auth::{CurrentUser, origin},
    auth_api::throttle,
    models::{
        auth_token::{self, EMAIL_CONFIRMATION, valid_minutes},
        user::{self, User},
    },
};

pub fn routes() -> Router<Ctx> {
    Router::new()
        .route("/confirmations", post(create))
        .route("/confirmations/{token}", get(show).post(update))
}

#[derive(Template)]
#[template(path = "auth/confirmation_show.html")]
struct ShowView {
    token: String,
}

/// Emails `user` a confirmation link (one D1 batch, one email).
pub async fn send_confirmation(ctx: &Ctx, uri: &Uri, user: &User) -> Result<()> {
    let token = auth_token::issue(ctx, user.id, EMAIL_CONFIRMATION).await?;
    let link = format!("{}/confirmations/{token}", origin(uri));
    let hours = valid_minutes(EMAIL_CONFIRMATION) / 60;
    let text = format!(
        "Confirm your email address by opening this link within {hours} hours:\n\n{link}\n\nIf you did not sign up, ignore this email.\n"
    );
    let html = format!(
        "<p><a href=\"{link}\">Confirm your email address</a> (valid {hours} hours).</p><p>If you did not sign up, ignore this email.</p>"
    );
    mail::send(ctx, Email::new(&user.email, "Confirm your email address", text).html(html)).await
}

/// Sends a new link to the signed-in user.
async fn create(
    State(ctx): State<Ctx>,
    CurrentUser(user): CurrentUser,
    session: Session,
    headers: HeaderMap,
    uri: Uri,
) -> Result<Redirect> {
    throttle(&ctx, &headers, "confirmation").await?;
    if user.confirmed() {
        session.flash("notice", "Your email address is already confirmed.")?;
    } else {
        send_confirmation(&ctx, &uri, &user).await?;
        session.flash("notice", "A new confirmation link is on its way.")?;
    }
    Ok(Redirect::to("/account"))
}

/// The emailed link opens a page with a button: mail scanners that follow
/// links do not use up the single-use token.
async fn show(Path(token): Path<String>) -> Result<Html<String>> {
    render(&ShowView { token })
}

async fn update(State(ctx): State<Ctx>, session: Session, Path(token): Path<String>) -> Result<Redirect> {
    match auth_token::consume(&ctx, EMAIL_CONFIRMATION, &token).await? {
        Some(user_id) => {
            user::confirm(&ctx, user_id).await?;
            session.flash("notice", "Thanks, your email address is confirmed.")?;
        }
        None => session.flash("alert", "That confirmation link is invalid or has expired.")?,
    }
    Ok(Redirect::to("/account"))
}