ocre-cli 0.2.0

Command-line tool for Ocre: create, generate, migrate, run and deploy apps.
//! `ocre generate push`: web push notifications on top of `ocre g pwa`'s
//! service worker. `src/push.rs` stores the browsers' subscriptions in
//! `push_subscriptions` and sends to them (`notify_all`, or `notify_user`
//! after `ocre g auth`); `push.js` subscribes the browser from a button
//! with `data-push-subscribe`; a VAPID key pair goes to `.dev.vars`.

use super::{Edits, next_migration_path, register_routes, with_ocre_feature};
use crate::{CliResult, config, output::CliError, project::Project};

const LAYOUT: &str = "templates/layout.html";

const SCRIPT: &str = r#"// Web push subscriptions, generated by `ocre g push`. A click on an element
// with `data-push-subscribe` asks for permission (browsers only allow it
// after a click) and subscribes this browser: `push:subscribed` then fires
// on the element, or `push:error` (call preventDefault() to skip the alert).
// Safari on iPhone and iPad allows it once the site is on the Home Screen.
(() => {
  const bytes = (base64) =>
    Uint8Array.from(atob(base64.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (base64.length % 4)) % 4)), (c) =>
      c.charCodeAt(0),
    );

  const subscribe = async () => {
    if (!("serviceWorker" in navigator) || !("PushManager" in window)) throw new Error("This browser cannot receive notifications.");
    if ((await Notification.requestPermission()) !== "granted") throw new Error("Notifications are blocked for this site.");
    const registration = await navigator.serviceWorker.ready;
    const key = await (await fetch("/push/key")).text();
    const subscription =
      (await registration.pushManager.getSubscription()) ||
      (await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: bytes(key) }));
    const response = await fetch("/push/subscriptions", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(subscription),
    });
    if (!response.ok) throw new Error(`The app answered ${response.status}.`);
    return subscription;
  };

  window.ocrePush = { subscribe };
  document.addEventListener("click", async (event) => {
    const button = event.target.closest("[data-push-subscribe]");
    if (!button) return;
    event.preventDefault();
    try {
      await subscribe();
      button.dispatchEvent(new CustomEvent("push:subscribed", { bubbles: true }));
    } catch (error) {
      const unhandled = button.dispatchEvent(new CustomEvent("push:error", { bubbles: true, cancelable: true, detail: { error } }));
      if (unhandled) alert(error.message);
    }
  });
})();
"#;

const TESTS: &str = r#"//! Request tests for web push subscriptions. Generated by `ocre g push`.
//! They run against the app in workerd with `ocre test --e2e`; plain
//! `cargo test` skips them (`#[ignore]`).

use ocre::{
    serde_json::json,
    testing::{self, Client},
};

fn subscription(endpoint: &str) -> ocre::serde_json::Value {
    json!({ "endpoint": endpoint, "expirationTime": null, "keys": { "p256dh": "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4", "auth": "BTBZMqHH6r4Tts7J_aSIgg" } })
}

#[test]
#[ignore = "request test: run with `ocre test --e2e`"]
fn browsers_get_the_public_key() {
    let key = testing::var("VAPID_PUBLIC_KEY").expect("VAPID_PUBLIC_KEY is in .dev.vars");
    Client::new().get("/push/key").assert_status(200).assert_contains(&key);
}

#[test]
#[ignore = "request test: run with `ocre test --e2e`"]
fn a_browser_subscribes_once_and_unsubscribes() {
    let endpoint = format!("https://push.example/send/{}", ocre::token::public_id());
    let mut client = Client::new();
    client.post_json("/push/subscriptions", &subscription(&endpoint)).assert_status(201);
    client.post_json("/push/subscriptions", &subscription(&endpoint)).assert_status(201);
    client.post_json("/push/subscriptions/delete", &json!({ "endpoint": endpoint })).assert_status(204);
    client.post_json("/push/subscriptions", &subscription("http://insecure.example/1")).assert_status(400);
}
"#;

pub fn push(project: &Project) -> CliResult {
    let assets = config::assets_directory(&project.root);
    let assets = assets.as_deref().unwrap_or("public");
    let mut edits = Edits::new(project);
    if !edits.exists(&format!("{assets}/service-worker.js")) {
        return Err(CliError::new(format!("{assets}/service-worker.js not found"))
            .hint("push messages are shown by the service worker of `ocre g pwa`: run it first"));
    }
    let layout = edits.read(LAYOUT)?.unwrap_or_default();
    let head = layout.find("</head>").ok_or_else(|| {
        CliError::new(format!("{LAYOUT} has no </head>")).hint("add the <head> element back to the layout")
    })?;
    let auth = edits.exists("src/auth.rs");
    edits.create("src/push.rs", push_rs(auth))?;
    edits.create(&format!("{assets}/push.js"), SCRIPT.to_owned())?;
    edits.create("tests/push.rs", TESTS.to_owned())?;
    let user = if auth { "    user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,\n" } else { "" };
    let index =
        if auth { "\nCREATE INDEX index_push_subscriptions_on_user_id ON push_subscriptions (user_id);\n" } else { "" };
    if !edits.has_create_migration("push_subscriptions")? {
        let path = next_migration_path(&edits, "create_push_subscriptions")?;
        let sql = format!(
            "-- Generated by `ocre g push`: the browsers that receive the app's notifications.\nCREATE TABLE push_subscriptions (\n    id INTEGER PRIMARY KEY,\n    endpoint TEXT NOT NULL UNIQUE,\n    p256dh TEXT NOT NULL,\n    auth TEXT NOT NULL,\n{user}    created_at TEXT NOT NULL DEFAULT (datetime('now'))\n);\n{index}"
        );
        edits.create(&path, sql)?;
    }
    if !layout.contains("/push.js") {
        edits.update(
            LAYOUT,
            format!("{}  <script src=\"/push.js\" defer></script>\n{}", &layout[..head], &layout[head..]),
        );
    }
    let cargo = edits.read("Cargo.toml")?.unwrap_or_default();
    edits.update("Cargo.toml", with_ocre_feature(&cargo, "push")?);
    if let Some(vars) = edits.read(".dev.vars")?.filter(|vars| !vars.contains("VAPID_PUBLIC_KEY=")) {
        let keys = ocre::push::VapidKeys::generate();
        let vars = if vars.is_empty() || vars.ends_with('\n') { vars } else { format!("{vars}\n") };
        edits.update(
            ".dev.vars",
            format!(
                "{vars}VAPID_PUBLIC_KEY={}\nVAPID_PRIVATE_KEY={}\nVAPID_SUBJECT=mailto:you@example.com\n",
                keys.public_key, keys.private_key
            ),
        );
    }
    register_routes(&mut edits, "push")?;
    let mut report = edits.apply("generate push")?;
    let send = if auth { "push::notify_user(&ctx, user_id, &message)" } else { "push::notify_all(&ctx, &message)" };
    report.next = vec![
        "ocre migrate".to_owned(),
        "add a button to a page: <button data-push-subscribe>Notify me</button>".to_owned(),
        format!("send: let message = ocre::push::message(\"Title\", \"Body\", \"/path\"); crate::{send}.await?"),
        "production: `ocre push-keys >> .prod.vars` (a new key pair), VAPID_PUBLIC_KEY and VAPID_SUBJECT in worker.env, then `ocre secrets push VAPID_PRIVATE_KEY --file .prod.vars`".to_owned(),
    ];
    Ok(report)
}

fn push_rs(auth: bool) -> String {
    let (import, extractor, user_column, user_value, user_set, notify) = if auth {
        (
            "use crate::auth::OptionalUser;\n",
            " OptionalUser(user): OptionalUser,",
            ", user_id",
            ", ?4",
            ", user_id = ?4",
            r#"/// Sends `message` to the browsers of the user `user_id` (one subrequest
/// each, so at most 40 here), deleting the subscriptions that are gone.
/// Returns how many push services accepted it.
pub async fn notify_user(ctx: &Ctx, user_id: i64, message: &Value) -> Result<usize> {
    let sql = "SELECT id, endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = ?1 ORDER BY id DESC LIMIT 40";
    let rows: Vec<Row> = ctx.db()?.all(sql, params![user_id]).await?;
    send_to(ctx, rows, message).await
}
"#,
        )
    } else {
        (
            "",
            "",
            "",
            "",
            "",
            r#"/// Sends `message` to the subscribed browsers, at most 40 per call (one
/// subrequest each; enqueue jobs for more), oldest subscription first,
/// deleting the ones that are gone. Returns how many push services accepted it.
pub async fn notify_all(ctx: &Ctx, message: &Value) -> Result<usize> {
    let sql = "SELECT id, endpoint, p256dh, auth FROM push_subscriptions ORDER BY id LIMIT 40";
    let rows: Vec<Row> = ctx.db()?.all(sql, params![]).await?;
    send_to(ctx, rows, message).await
}
"#,
        )
    };
    let user_param = if auth { ", user.map(|user| user.id)" } else { "" };
    format!(
        r#"//! Web push notifications. Generated by `ocre g push`.
//!
//! `push.js` subscribes a browser (a click on `data-push-subscribe`) and
//! posts its subscription here; the service worker of `ocre g pwa` shows
//! the messages. Send one with `ocre::push::message(title, body, path)`:
//! clicking the notification opens `path`. Messages are encrypted for each
//! browser and signed with the VAPID keys (.dev.vars; production: worker.env
//! and a secret).

use axum::{{
    Json, Router,
    extract::State,
    http::StatusCode,
    routing::{{get, post}},
}};
use ocre::{{
    Ctx, Error, Result, params,
    push::{{self, Sent, Subscription, SubscriptionKeys}},
    serde_json::Value,
}};
use serde::Deserialize;
{import}
pub fn routes() -> Router<Ctx> {{
    Router::new()
        .route("/push/key", get(key))
        .route("/push/subscriptions", post(subscribe))
        .route("/push/subscriptions/delete", post(unsubscribe))
}}

/// The public VAPID key browsers subscribe with.
async fn key(State(ctx): State<Ctx>) -> Result<String> {{
    let key = ctx.env().var(push::VAPID_PUBLIC_KEY).map_err(|_| Error::internal("VAPID_PUBLIC_KEY is not set"))?;
    Ok(key.to_string())
}}

/// Saves a browser's subscription; the same browser subscribing again updates it.
async fn subscribe(State(ctx): State<Ctx>,{extractor} Json(subscription): Json<Subscription>) -> Result<StatusCode> {{
    let valid = subscription.endpoint.starts_with("https://") && subscription.endpoint.len() <= 1024;
    if !valid {{
        return Err(Error::bad_request("a push endpoint is an https:// URL"));
    }}
    let Subscription {{ endpoint, keys: SubscriptionKeys {{ p256dh, auth }} }} = subscription;
    ctx.db()?
        .execute(
            "INSERT INTO push_subscriptions (endpoint, p256dh, auth{user_column}) VALUES (?1, ?2, ?3{user_value}) \
             ON CONFLICT (endpoint) DO UPDATE SET p256dh = ?2, auth = ?3{user_set}",
            params![endpoint, p256dh, auth{user_param}],
        )
        .await?;
    Ok(StatusCode::CREATED)
}}

#[derive(Deserialize)]
struct Endpoint {{
    endpoint: String,
}}

/// Forgets a browser (after `subscription.unsubscribe()` in the page).
async fn unsubscribe(State(ctx): State<Ctx>, Json(Endpoint {{ endpoint }}): Json<Endpoint>) -> Result<StatusCode> {{
    ctx.db()?.execute("DELETE FROM push_subscriptions WHERE endpoint = ?1", params![endpoint]).await?;
    Ok(StatusCode::NO_CONTENT)
}}

#[derive(Deserialize)]
struct Row {{
    id: i64,
    endpoint: String,
    p256dh: String,
    auth: String,
}}

{notify}
/// Sends to each row, kept a day while the browser is offline.
async fn send_to(ctx: &Ctx, rows: Vec<Row>, message: &Value) -> Result<usize> {{
    let mut delivered = 0;
    for row in rows {{
        let subscription = Subscription {{ endpoint: row.endpoint, keys: SubscriptionKeys {{ p256dh: row.p256dh, auth: row.auth }} }};
        match push::send(ctx, &subscription, message, 24 * 3600).await {{
            Ok(Sent::Delivered) => delivered += 1,
            // Unsubscribed or expired: forget it.
            Ok(Sent::Gone) => {{
                ctx.db()?.execute("DELETE FROM push_subscriptions WHERE id = ?1", params![row.id]).await?;
            }}
            // One broken subscription does not stop the others.
            Err(err) => ctx.log().warn(format!("push to subscription {{}} failed: {{err}}", row.id)),
        }}
    }}
    Ok(delivered)
}}
"#
    )
}