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)
}}
"#
)
}