//! Push notifications: `dev.knotless.registerPush` and `unregisterPush`, //! which keep the devices an account hears on, and the round that tells //! each device of what is new, through Apple's push service. What is new //! is what `listNotifications` would answer, so an account's choices, //! blocks and mutes hold here too. use super::notifications::{Page, asking, page}; use super::*; use crate::index::{Notified, PushDevice}; use crate::viewer::Viewer; use anyhow::Context; use axum::Extension; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use p256::ecdsa::signature::Signer; use p256::ecdsa::{Signature, SigningKey}; use p256::pkcs8::DecodePrivateKey; use serde_json::{Value, json}; use std::sync::Mutex; use std::time::Instant; /// The app a notification is for: the topic Apple asks of each. const TOPIC: &str = "dev.knotless.app"; /// How long the token signed for Apple is used: it refuses one over an /// hour old, and one signed anew more often than every twenty minutes. const SIGNED_FOR: Duration = Duration::from_secs(50 * 60); /// How long Apple is given to answer one notification. const WAIT: Duration = Duration::from_secs(15); /// How many notifications an account reads each round, which is also the /// most its badge counts. const READ: u32 = 100; /// The most a device is told of one by one in a round: past that, it is /// told how many there are. const ONE_BY_ONE: usize = 3; /// The most of a title or a line a notification carries, as Apple takes /// four kilobytes of it. const CLIP: usize = 200; /// A device's token: its bytes in hexadecimal, as it goes into a URL. fn is_token(token: &str) -> bool { !token.is_empty() && token.len() <= 200 && token.bytes().all(|b| b.is_ascii_hexdigit()) } /// Who asks to change its devices, as `update_seen` allows it: with a /// token, or the pages' cookie from the site itself. fn asking_to_write(app: &App, viewer: Viewer, headers: &HeaderMap) -> Result<(String, Arc)> { let token = headers.contains_key(header::AUTHORIZATION); if !token && !crate::login::same_site(headers) { return Err(Shown(StatusCode::FORBIDDEN, "not from this site".into())); } asking(app, viewer) } #[derive(Deserialize)] pub struct RegisterInput { token: String, environment: String, } /// `dev.knotless.registerPush`. pub async fn register_push( State(app): State>, Extension(viewer): Extension, headers: HeaderMap, axum::Json(input): axum::Json, ) -> Result { let (did, index) = asking_to_write(&app, viewer, &headers)?; if !is_token(&input.token) { let why = "a push token is hexadecimal, of at most 200 digits"; return Err(Shown(StatusCode::BAD_REQUEST, why.into())); } if !["production", "sandbox"].contains(&input.environment.as_str()) { let why = format!("{:?} is not production or sandbox", input.environment); return Err(Shown(StatusCode::BAD_REQUEST, why)); } let now = knotless::records::now(); let kept = index.register_push(&did, &input.token, &input.environment, &now); kept.map_err(anyhow::Error::from) .map_err(AppError::internal)?; Ok(answer(json!({}))) } #[derive(Deserialize)] pub struct UnregisterInput { token: String, } /// `dev.knotless.unregisterPush`. pub async fn unregister_push( State(app): State>, Extension(viewer): Extension, headers: HeaderMap, axum::Json(input): axum::Json, ) -> Result { let (did, index) = asking_to_write(&app, viewer, &headers)?; let forgot = index.unregister_push(&did, &input.token); forgot .map_err(anyhow::Error::from) .map_err(AppError::internal)?; Ok(answer(json!({}))) } /// Apple's push service, and what this server signs in to it with. pub struct Apns { key: SigningKey, key_id: String, team: String, /// Where a device's notification goes, by the environment it says it /// is in. Other than Apple's only in tests. pub production: String, pub sandbox: String, client: reqwest::Client, signed: Mutex>, } impl Apns { /// With the key Apple gave, in the `.p8` file at `path`. pub fn open( path: &std::path::Path, key_id: Option<&str>, team: Option<&str>, ) -> anyhow::Result { let Some((key_id, team)) = key_id.zip(team) else { anyhow::bail!("--apns-key takes --apns-key-id and --apns-team"); }; let pem = std::fs::read_to_string(path); let pem = pem.with_context(|| format!("reading {}", path.display()))?; let key = SigningKey::from_pkcs8_pem(&pem); let key = key.map_err(|err| anyhow::anyhow!("{}: {err}", path.display()))?; let client = reqwest::Client::builder().timeout(WAIT).build()?; Ok(Apns { key, key_id: key_id.into(), team: team.into(), production: "https://api.push.apple.com".into(), sandbox: "https://api.sandbox.push.apple.com".into(), client, signed: Mutex::new(None), }) } /// The token Apple takes for now: the last one, until it is too old. fn signed(&self) -> String { let mut kept = self.signed.lock().unwrap(); let fresh = kept.take().filter(|(at, _)| at.elapsed() < SIGNED_FOR); let fresh = fresh.unwrap_or_else(|| (Instant::now(), self.sign())); let token = fresh.1.clone(); *kept = Some(fresh); token } /// A token for Apple: ES256, naming the key and the team. fn sign(&self) -> String { let part = |value: Value| URL_SAFE_NO_PAD.encode(value.to_string()); let head = part(json!({"alg": "ES256", "kid": self.key_id})); let claims = part(json!({"iss": self.team, "iat": now_secs()})); let message = format!("{head}.{claims}"); let signature: Signature = self.key.sign(message.as_bytes()); let signature = URL_SAFE_NO_PAD.encode(signature.to_bytes()); format!("{message}.{signature}") } /// Tells `device`, saying what became of it. async fn send(&self, device: &PushDevice, message: &Value) -> Sent { let base = match device.environment.as_str() { "sandbox" => &self.sandbox, _ => &self.production, }; let asked = self .client .post(format!("{base}/3/device/{}", device.token)) .bearer_auth(self.signed()) .header("apns-topic", TOPIC) .header("apns-push-type", "alert") .json(message) .send() .await; let res = match asked { Ok(res) => res, Err(err) => return failed(&device.did, &err.to_string()), }; let status = res.status(); if status.is_success() { return Sent::Told; } let said = res.text().await.unwrap_or_default(); let gone = ["BadDeviceToken", "DeviceTokenNotForTopic"]; if status == StatusCode::GONE || gone.iter().any(|why| said.contains(why)) { return Sent::Gone; } failed(&device.did, &format!("{status}: {said}")) } } /// What became of a notification. #[derive(Debug, PartialEq)] enum Sent { Told, /// The device no longer takes them: the app is gone from it, say. Gone, /// It may do later, so it is asked again next round. Failed, } /// Logs a notification Apple did not take. fn failed(did: &str, why: &str) -> Sent { let fields = knotless::telemetry::Fields { did: Some(did), error: Some(why.to_string()), ..Default::default() }; knotless::telemetry::note(knotless::telemetry::Level::WARN, "push not sent", &fields); Sent::Failed } /// What a read gave, or nothing if it failed, logged: the next round /// reads it again. fn or_nothing(read: std::result::Result) -> T { read.unwrap_or_else(|err| { failed("", &format!("reading: {err:?}")); T::default() }) } /// Tells every device of what is new, every `every`, for as long as the /// server runs. pub async fn follow(app: Arc, index: Arc, apns: Apns, every: Duration) { loop { round(&app, &index, &apns).await; tokio::time::sleep(every).await; } } /// Tells every device once of what is new since it was last told. pub async fn round(app: &App, index: &Index, apns: &Apns) { let devices = or_nothing(index.push_devices()); for same in devices.chunk_by(|a, b| a.did == b.did) { let did = &same[0].did; let all = Page { limit: READ, after: None, kind: None, }; let (found, _) = or_nothing(page(app, index, did, all).await); let seen = or_nothing(index.seen(did)); let now = knotless::records::now(); let badge = found.iter().filter(|n| unread(n, seen.as_deref())).count(); for device in same { let due = due(&found, device, seen.as_deref(), &now); tell(app, index, apns, device, &due, badge).await; } } } /// Whether `n` came after its account last looked. fn unread(n: &Notified, seen: Option<&str>) -> bool { seen.is_none_or(|seen| n.at.as_str() > seen) } /// Of `found`, the latest first, those `device` has yet to hear of, the /// oldest first. One dated after `now` waits for then, as moving the /// device's time on for it would silence all before that date. fn due<'a>( found: &'a [Notified], device: &PushDevice, seen: Option<&str>, now: &str, ) -> Vec<&'a Notified> { let new = |n: &&Notified| n.at > device.at && n.at.as_str() <= now && unread(n, seen); found.iter().rev().filter(new).collect() } /// Tells `device` of `due`, the oldest first, and keeps how far it got. async fn tell( app: &App, index: &Index, apns: &Apns, device: &PushDevice, due: &[&Notified], badge: usize, ) { let mut messages = Vec::new(); if due.len() > ONE_BY_ONE { let newest = due.iter().map(|n| n.at.as_str()).max().unwrap_or_default(); let body = format!("{} new notifications", due.len()); messages.push((newest, plain(&body, badge))); } else { for n in due { messages.push((n.at.as_str(), message(app, n, badge).await)); } } for (at, message) in messages { match apns.send(device, &message).await { Sent::Told => or_nothing(index.pushed(&device.token, at)), Sent::Gone => return or_nothing(index.unregister_push(&device.did, &device.token)), Sent::Failed => return, } } } /// A notification that names nothing, which opens the inbox. fn plain(body: &str, badge: usize) -> Value { json!({"aps": { "alert": {"title": "Knotless", "body": body}, "badge": badge, "sound": "default", }}) } /// The notification for `n`. One about a private repository says nothing /// of it: its title, its text and who is in it stay away from Apple. pub async fn message(app: &App, n: &Notified, badge: usize) -> Value { if n.private { return plain("New activity in a private repository", badge); } let author = handle(app, &n.author).await; let repo = match &n.repo { Some((did, name)) => Some((did.as_str(), handle(app, did).await, name.as_str())), None => None, }; let title = match &repo { Some((_, owner, name)) => format!("{owner}/{name}"), None => author.clone(), }; let mut alert = json!({"title": title, "body": body(n, &author)}); if let Some(subtitle) = &n.title { alert["subtitle"] = json!(clip(subtitle)); } let mut aps = json!({"alert": alert, "badge": badge, "sound": "default"}); if let Some(subject) = &n.subject { aps["thread-id"] = json!(subject); } let mut message = json!({"aps": aps, "author": n.author}); if let Some(subject) = &n.subject { message["subject"] = json!(subject); } if let Some((did, owner, name)) = repo { message["repo"] = json!({"did": did, "handle": owner, "name": name}); } message } /// What a notification says: who did what, then the start of what they /// said. fn body(n: &Notified, author: &str) -> String { let what = match (what(n), n.reason.as_str()) { (what, "checks-failed" | "ejected") => what.to_string(), (what, _) => format!("{author} {what}"), }; let line = n.text.as_deref().and_then(|t| t.lines().next()); match line { Some(line) => format!("{what}\n{}", clip(line)), None => what, } } /// What `n` says happened, as the app's inbox says it. fn what(n: &Notified) -> &'static str { let kind = n.uri.split('/').nth(3).unwrap_or_default(); match (n.reason.as_str(), n.verdict.as_deref()) { ("star", _) => "starred your repository", ("follow", _) => "followed you", ("invite", _) => "invited you to join", ("checks-failed", _) => "Checks failed", ("ejected", _) => "Left the merge queue", ("review-requested", _) => "asked for your review", ("assigned", _) => "assigned you", (_, Some("approve")) => "approved", (_, Some("changes")) => "asked for changes", _ if n.mentioned => "mentioned you", _ if kind == knotless::records::nsid::PULL => "opened a pull", _ if kind == knotless::records::nsid::ISSUE => "opened an issue", _ if kind == knotless::records::nsid::RELEASE => "published a release", _ => "commented", } } /// `text`, no longer than [`CLIP`] characters. fn clip(text: &str) -> String { text.chars().take(CLIP).collect() } #[cfg(test)] mod tests { use super::*; fn notified(uri: &str, reason: &str, at: &str) -> Notified { Notified { uri: uri.into(), author: "did:a".into(), reason: reason.into(), subject: None, repo: None, title: None, at: at.into(), verdict: None, mentioned: false, line_comments: None, role: None, checks: Vec::new(), text: None, reviewed: false, state: None, private: false, } } #[test] fn a_token_is_hexadecimal() { assert!(is_token("0aF9")); assert!(!is_token("")); assert!(!is_token("../../3/device")); assert!(!is_token(&"a".repeat(201))); } #[test] fn what_is_due_to_a_device() { let device = PushDevice { token: "aa".into(), did: "did:a".into(), environment: "production".into(), at: "2026-01-02T00:00:00.000Z".into(), }; let found = [ notified("future", "star", "2030-01-01T00:00:00.000Z"), notified("newer", "star", "2026-01-04T00:00:00.000Z"), notified("new", "star", "2026-01-03T00:00:00.000Z"), notified("told", "star", "2026-01-01T00:00:00.000Z"), ]; let now = "2026-06-01T00:00:00.000Z"; let uris = |seen: Option<&str>| { let due = due(&found, &device, seen, now); due.iter().map(|n| n.uri.clone()).collect::>() }; assert_eq!(uris(None), ["new", "newer"]); assert_eq!(uris(Some("2026-01-03T12:00:00.000Z")), ["newer"]); assert!(uris(Some("2026-02-01T00:00:00.000Z")).is_empty()); } #[test] fn what_each_says() { let says = |reason: &str, uri: &str, verdict: Option<&str>, mentioned: bool| { let mut n = notified(uri, reason, ""); n.verdict = verdict.map(String::from); n.mentioned = mentioned; what(&n) }; let comment = "at://did:a/dev.knotless.comment/1"; let cases = [ ("star", "starred your repository"), ("follow", "followed you"), ("invite", "invited you to join"), ("checks-failed", "Checks failed"), ("ejected", "Left the merge queue"), ("review-requested", "asked for your review"), ("assigned", "assigned you"), ("comment", "commented"), ]; for (reason, said) in cases { assert_eq!(says(reason, comment, None, false), said); } assert_eq!(says("comment", comment, Some("approve"), false), "approved"); let changes = says("comment", comment, Some("changes"), false); assert_eq!(changes, "asked for changes"); assert_eq!(says("mention", comment, None, true), "mentioned you"); let pull = says("pull", "at://did:a/dev.knotless.pull/1", None, false); assert_eq!(pull, "opened a pull"); let issue = says("issue", "at://did:a/dev.knotless.issue/1", None, false); assert_eq!(issue, "opened an issue"); let release = says("watching", "at://did:a/dev.knotless.release/1", None, false); assert_eq!(release, "published a release"); } #[test] fn what_a_notification_reads() { let mut n = notified("at://did:a/dev.knotless.comment/1", "comment", ""); assert_eq!(body(&n, "ann.test"), "ann.test commented"); n.text = Some(format!("{}\nand more", "a".repeat(300))); let read = body(&n, "ann.test"); assert_eq!(read, format!("ann.test commented\n{}", "a".repeat(200))); let failed = notified("at://did:a/dev.knotless.pull/1", "checks-failed", ""); assert_eq!(body(&failed, "ann.test"), "Checks failed"); } #[test] fn a_failed_read_is_nothing() { let read: Vec = or_nothing(Err("no")); assert!(read.is_empty()); assert_eq!(or_nothing::<_, &str>(Ok(vec![1])), [1]); } }