autumn-cli 0.10.0

CLI tool for the Autumn web framework
use autumn_web::form::skip_link;
use autumn_web::migrate::{EmbeddedMigrations, embed_migrations};
use autumn_web::prelude::*;

const MIGRATIONS: EmbeddedMigrations = embed_migrations!();

// Bump this when the cookie policy changes (e.g. a new analytics vendor is
// added). A visitor's prior consent recorded under an older version is
// treated as revoked — see `autumn_web::consent::Consent::needs_prompt` — so
// the banner reappears and the gate closes until they decide again.
const CONSENT_POLICY_VERSION: u32 = 1;

// Embed the `static/` tree (and its fingerprint manifest) into the binary for
// single-binary deploys. Active only when built with the `embed-assets` feature
// (`autumn build --embed`); in dev the app serves from disk for hot-reload.
#[cfg(feature = "embed-assets")]
static EMBEDDED_STATIC: autumn_web::include_dir::Dir = autumn_web::embed_static!();

// `flash` carries one-shot notices set with `flash.success(...)` before a
// redirect. Pass `flash_messages(&flash.consume().await)` from your handlers;
// it renders any pending messages (and clears them) and is harmless when empty.
//
// `current_path` (from the `CurrentPath` extractor) lets nav_bar mark the
// link matching the current page with `aria-current="page"`.
pub fn layout(title: &str, current_path: &str, flash: maud::Markup, content: maud::Markup) -> maud::Markup {
    let nav = NavBarConfig::new()
        .brand("{{project_name}}", "/")
        .aria_label("Main navigation")
        .item(NavItem::link("/", "Home"));
    maud::html! {
        (maud::DOCTYPE)
        html lang="en" {
            head {
                meta charset="utf-8";
                meta name="viewport" content="width=device-width, initial-scale=1";
                title { (title) }
                link rel="stylesheet" href=(autumn_web::flash::FLASH_CSS_PATH);
                link rel="stylesheet" href=(autumn_web::ui::WIDGETS_CSS_PATH);
                link rel="stylesheet" href="/static/css/app.css";
                (javascript_include_tag("htmx"))
                script src=(autumn_web::htmx::AUTUMN_WIDGETS_JS_PATH) defer {}
                // Agent readiness: the ARD link and the WebMCP script, which
                // lets in-browser AI agents call the app's MCP tools once you
                // mount MCP. A tool call is a POST, so with CSRF protection on
                // (the production default) it needs the page's CSRF token:
                // take `Option<CsrfToken>` in the handler, pass it to
                // `layout`, and hand `csrf.as_ref().map(CsrfToken::token)` in
                // as the second argument. Without one, the script offers no
                // tools in production (only read-only ones where CSRF is
                // off). Remove this line if you set `[aeo] enabled = false`:
                // both URLs then answer 404.
                (autumn_web::aeo::webmcp::head_tags(None, None))
            }
            body {
                (skip_link("#main-content", "Skip to main content"))
                header role="banner" {
                    (nav_bar(current_path, &nav))
                }
                main id="main-content" role="main" {
                    (flash)
                    (content)
                }
                footer role="contentinfo" {
                    p { "Built with Autumn" }
                    // Withdrawing consent must be as easy as giving it (GDPR
                    // Art. 7(3)): a plain GET link (no CSRF token needed, so
                    // it needs no per-handler wiring) that re-opens the
                    // banner by expiring the consent cookie.
                    p { a href="/consent/manage" { "Manage cookie preferences" } }
                }
            }
        }
    }
}

// `#[public]` marks each handler below deliberately unauthenticated — see
// `autumn routes audit` / docs/guide/route-auth-coverage.md. Replace it with
// `#[secured]`/`#[authorize]` on any route that should require a signed-in
// user or a dynamic policy check.
#[get("/")]
#[public]
async fn index(flash: Flash, path: CurrentPath) -> maud::Markup {
    layout("Welcome", path.as_str(), flash_messages(&flash.consume().await), maud::html! {
        h1 { "Welcome to {{project_name}}!" }
        p { "Edit " code { "src/main.rs" } " to get started." }
    })
}

#[get("/hello")]
#[public]
async fn hello() -> &'static str {
    "Hello, Autumn!"
}

#[get("/hello/{name}")]
#[public]
async fn hello_name(name: autumn_web::extract::Path<String>) -> String {
    format!("Hello, {}!", *name)
}

// Cookie-consent banner (issue #1214): `inject_consent_banner` (wired below)
// shows the banner on every HTML page until one of these routes is hit. Add
// more categories to `accept_all_cookie`'s list as the app grows non-essential
// cookies (e.g. `&["analytics", "marketing"]`), and gate each one at its call
// site with `consent.allows("analytics", CONSENT_POLICY_VERSION)`. Both
// routes redirect back to the page the visitor was on (via `Referer`, kept
// same-origin by `redirect_target_from_referer`) rather than always bouncing
// to the homepage.
#[post("/consent/accept")]
#[public]
async fn consent_accept(headers: autumn_web::reexports::http::HeaderMap) -> impl IntoResponse {
    let cookie = autumn_web::consent::accept_all_cookie(&["analytics"], CONSENT_POLICY_VERSION);
    let target = autumn_web::consent::redirect_target_from_referer(
        headers
            .get(autumn_web::reexports::http::header::REFERER)
            .and_then(|v| v.to_str().ok()),
    );
    (
        [(autumn_web::reexports::http::header::SET_COOKIE, cookie)],
        Redirect::to(&target),
    )
}

#[post("/consent/reject")]
#[public]
async fn consent_reject(headers: autumn_web::reexports::http::HeaderMap) -> impl IntoResponse {
    let cookie = autumn_web::consent::reject_non_essential_cookie(CONSENT_POLICY_VERSION);
    let target = autumn_web::consent::redirect_target_from_referer(
        headers
            .get(autumn_web::reexports::http::header::REFERER)
            .and_then(|v| v.to_str().ok()),
    );
    (
        [(autumn_web::reexports::http::header::SET_COOKIE, cookie)],
        Redirect::to(&target),
    )
}

// A plain `GET` (reachable from a footer link on every page) that renders a
// preferences page reusing the consent-banner widget, so withdrawing consent
// stays as easy as giving it (GDPR Art. 7(3)) without making the withdrawal
// itself a bare GET: `GET` is CSRF-exempt by definition, so a same-origin
// prefetcher, browser extension, or cross-site top-level navigation
// following this link must not be able to silently reset consent on its own.
// The actual state change happens through the existing CSRF-protected
// `POST /consent/accept` / `POST /consent/reject` above.
#[get("/consent/manage")]
#[public]
async fn consent_manage(
    flash: Flash,
    path: CurrentPath,
    csrf: Option<autumn_web::security::CsrfToken>,
) -> impl IntoResponse {
    let markup = layout(
        "Manage cookie preferences",
        path.as_str(),
        flash_messages(&flash.consume().await),
        autumn_web::consent::consent_banner_markup(
            csrf.as_ref().map(autumn_web::security::CsrfToken::token),
            autumn_web::consent::DEFAULT_CSRF_FORM_FIELD,
        ),
    );
    // This page embeds the visitor's own live CSRF token, so — like
    // `inject_consent_banner`'s own injected banner — it must never be
    // shared across visitors by a cache: a shared/public cache serving this
    // response to someone else would leak one visitor's token to another
    // and make every other visitor's own consent forms fail CSRF
    // validation.
    (
        [
            (
                autumn_web::reexports::http::header::CACHE_CONTROL,
                "private, no-store",
            ),
            (autumn_web::reexports::http::header::VARY, "Cookie"),
        ],
        markup,
    )
}

#[autumn_web::main]
async fn main() {
    let app = autumn_web::app()
        .routes(routes![index, hello, hello_name, consent_accept, consent_reject, consent_manage])
        .migrations(MIGRATIONS)
        // Strictly-necessary cookies (session, CSRF) are never gated — only
        // this banner/non-essential-category flow depends on consent.
        .layer(autumn_web::reexports::axum::middleware::from_fn(move |req, next| async move {
            autumn_web::consent::inject_consent_banner(
                req,
                next,
                CONSENT_POLICY_VERSION,
                Some(autumn_web::consent::DEFAULT_CSRF_COOKIE_NAME),
                autumn_web::consent::DEFAULT_CSRF_FORM_FIELD,
            )
            .await
        }));

    // Single-binary deploys: serve static assets (and i18n locales) from the
    // binary when built with `autumn build --embed`.
    #[cfg(feature = "embed-assets")]
    let app = app.embedded_static(&EMBEDDED_STATIC);

    app
        .run()
        .await;
}