|
|
@@ -46,6 +46,21 @@ namespace ClankerSaddle.Services {
|
|
|
public string url { get; set; default = ""; }
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * The roll-up of one {@link PushService.send_test} probe: counts
|
|
|
+ * per outcome plus the first failure's detail for the toast.
|
|
|
+ * `first_failure` names the HTTP status ("HTTP 401") or the
|
|
|
+ * transport error text — the dashboard's test button toasts the
|
|
|
+ * REAL end-to-end result, never a blind success.
|
|
|
+ */
|
|
|
+ public class PushTestReport : Object {
|
|
|
+ public int delivered { get; set; }
|
|
|
+ public int failed { get; set; }
|
|
|
+ /** Endpoints that answered 404/410 — pruned during the probe. */
|
|
|
+ public int pruned { get; set; }
|
|
|
+ public string first_failure { get; set; default = ""; }
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* Web Push delivery (plan 11 task E): real Web Push protocol over
|
|
|
* VAPID. The keypair is generated once and persisted in the config
|
|
|
@@ -92,6 +107,8 @@ namespace ClankerSaddle.Services {
|
|
|
public string auth = "";
|
|
|
public string payload = "";
|
|
|
public int attempts = 0;
|
|
|
+ /** The last HTTP status the endpoint answered (0 = transport error). */
|
|
|
+ public int last_status = 0;
|
|
|
}
|
|
|
|
|
|
/** A queued notification before target resolution. */
|
|
|
@@ -301,6 +318,21 @@ namespace ClankerSaddle.Services {
|
|
|
};
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * The "Send test notification" payload (per-browser bell): a
|
|
|
+ * REAL Web Push through the full RFC 8291 + VAPID path whose
|
|
|
+ * arrival the human can see — the end-to-end probe that was
|
|
|
+ * missing entirely.
|
|
|
+ */
|
|
|
+ public static PushNotification test_notification() {
|
|
|
+ return new PushNotification() {
|
|
|
+ title = "Test notification",
|
|
|
+ body = "A live Web Push from Clanker Saddle — if you can"
|
|
|
+ + " read this, delivery works.",
|
|
|
+ url = "/"
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
// ------------------------------------------------------------------
|
|
|
// Enqueue + delivery
|
|
|
// ------------------------------------------------------------------
|
|
|
@@ -358,7 +390,15 @@ namespace ClankerSaddle.Services {
|
|
|
if (job == null) {
|
|
|
break;
|
|
|
}
|
|
|
- yield deliver((!)job);
|
|
|
+ bool requeued = yield deliver((!)job);
|
|
|
+ if (requeued) {
|
|
|
+ // A failed send goes back for a LATER pass:
|
|
|
+ // draining in place would burn every attempt in
|
|
|
+ // one tight loop. The dispatcher tick (or the
|
|
|
+ // next enqueue's flush) picks the job back up —
|
|
|
+ // that spacing IS the retry backoff.
|
|
|
+ break;
|
|
|
+ }
|
|
|
}
|
|
|
} finally {
|
|
|
flushing = false;
|
|
|
@@ -432,7 +472,15 @@ namespace ClankerSaddle.Services {
|
|
|
GONE
|
|
|
}
|
|
|
|
|
|
- private async void deliver(SendJob job) {
|
|
|
+ /**
|
|
|
+ * Applies one job's outcome: DELIVERED drops it, GONE prunes
|
|
|
+ * the subscription row, anything else requeues it for a later
|
|
|
+ * tick. Returns true when the job was requeued — the flush
|
|
|
+ * drain then STOPS (the retry waits for the next tick instead
|
|
|
+ * of burning every attempt back-to-back), which is what makes
|
|
|
+ * MAX_ATTEMPTS a real backoff rather than one hot loop.
|
|
|
+ */
|
|
|
+ private async bool deliver(SendJob job) {
|
|
|
SendOutcome outcome = SendOutcome.RETRY;
|
|
|
try {
|
|
|
outcome = yield deliver_once(job);
|
|
|
@@ -440,29 +488,87 @@ namespace ClankerSaddle.Services {
|
|
|
outcome = SendOutcome.RETRY;
|
|
|
}
|
|
|
if (outcome == SendOutcome.DELIVERED) {
|
|
|
- return;
|
|
|
+ return false;
|
|
|
}
|
|
|
if (outcome == SendOutcome.GONE) {
|
|
|
yield prune_subscription(job.endpoint);
|
|
|
- return;
|
|
|
+ return false;
|
|
|
}
|
|
|
job.attempts++;
|
|
|
if (job.attempts >= MAX_ATTEMPTS) {
|
|
|
- warning("[ClankerSaddle] Push to %s gave up after %d attempts",
|
|
|
+ // Giving up is loud on purpose: a silently dropped
|
|
|
+ // notification looks exactly like a delivered one.
|
|
|
+ warning("[ClankerSaddle] Push to %s gave up after %d attempts"
|
|
|
+ + " — notification dropped undelivered",
|
|
|
job.endpoint, job.attempts);
|
|
|
- return;
|
|
|
+ return false;
|
|
|
}
|
|
|
queue_lock.lock();
|
|
|
queued.push_tail(job);
|
|
|
queue_lock.unlock();
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * The dashboard's "Send test notification" probe (per-browser
|
|
|
+ * bell, shown only when THIS browser is subscribed): one REAL
|
|
|
+ * delivery attempt to each of the user's endpoints through the
|
|
|
+ * same {@link deliver_once} crypto + VAPID path the queue
|
|
|
+ * uses, awaited inline so the action can report per-endpoint
|
|
|
+ * outcomes. The shared queue's flush is single-flight and
|
|
|
+ * fire-and-forget — its outcomes are unobservable from a
|
|
|
+ * request — so the probe resolves and sends synchronously
|
|
|
+ * instead of enqueueing. A refused endpoint is reported rather
|
|
|
+ * than retried here (the queue's own backoff still governs
|
|
|
+ * real events); 404/410 endpoints are pruned exactly as the
|
|
|
+ * queue would, so the probe also heals dead rows on demand.
|
|
|
+ */
|
|
|
+ public async PushTestReport send_test(OrmSession db, int64 user_id) throws Error {
|
|
|
+ var report = new PushTestReport();
|
|
|
+ var jobs = new GLib.List<SendJob>();
|
|
|
+ foreach (var row in yield db.query<PushSubscription>()
|
|
|
+ .where(expr("user_id == $0", elem<int64?>(user_id)))
|
|
|
+ .materialise_async()) {
|
|
|
+ jobs.append(new SendJob() {
|
|
|
+ endpoint = row.endpoint,
|
|
|
+ p256dh = row.p256dh,
|
|
|
+ auth = row.auth,
|
|
|
+ payload = payload_json(test_notification())
|
|
|
+ });
|
|
|
+ }
|
|
|
+ foreach (var job in jobs) {
|
|
|
+ SendOutcome outcome = SendOutcome.RETRY;
|
|
|
+ string problem = "";
|
|
|
+ try {
|
|
|
+ outcome = yield deliver_once(job);
|
|
|
+ } catch (Error e) {
|
|
|
+ problem = e.message;
|
|
|
+ }
|
|
|
+ if (outcome == SendOutcome.DELIVERED) {
|
|
|
+ report.delivered++;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (outcome == SendOutcome.GONE) {
|
|
|
+ report.pruned++;
|
|
|
+ yield prune_subscription(job.endpoint);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ report.failed++;
|
|
|
+ if (report.first_failure.length == 0) {
|
|
|
+ report.first_failure = job.last_status > 0
|
|
|
+ ? @"HTTP $(job.last_status)" : problem;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return report;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* One Web Push protocol POST: RFC 8291 aes128gcm body, ES256
|
|
|
* VAPID JWT in the Authorization header. Transport errors and
|
|
|
* 5xx throw (retry); 404/410 report {@link SendOutcome.GONE}
|
|
|
- * (prune); any other status counts as delivered (the push
|
|
|
- * service owns delivery from here).
|
|
|
+ * (prune); any OTHER 4xx is a refusal — logged and retried,
|
|
|
+ * never silently delivered; remaining 2xx/3xx counts as
|
|
|
+ * delivered (the push service owns delivery from here).
|
|
|
*/
|
|
|
private async SendOutcome deliver_once(SendJob job) throws Error {
|
|
|
uint8[] client_public = base64url_decode(job.p256dh);
|
|
|
@@ -558,9 +664,25 @@ namespace ClankerSaddle.Services {
|
|
|
return SendOutcome.GONE;
|
|
|
}
|
|
|
if (message.status_code >= 500) {
|
|
|
+ job.last_status = (int) message.status_code;
|
|
|
throw new Error(Quark.from_string("clanker-saddle"), 65,
|
|
|
@"push service answered HTTP $(message.status_code)");
|
|
|
}
|
|
|
+ if (message.status_code >= 400) {
|
|
|
+ // 401/403 (VAPID rejected) and every other 4xx that is
|
|
|
+ // not the terminal 404/410 handled above: the push
|
|
|
+ // service did NOT accept the message. Truthful
|
|
|
+ // delivery — log it with the endpoint and status,
|
|
|
+ // retry on later ticks, give up loudly after
|
|
|
+ // MAX_ATTEMPTS, and never count it as delivered. The
|
|
|
+ // subscription row stays: the endpoint may recover
|
|
|
+ // (e.g. a VAPID key rotation heals on a later send).
|
|
|
+ job.last_status = (int) message.status_code;
|
|
|
+ warning("[ClankerSaddle] Push endpoint %s refused delivery:"
|
|
|
+ + " HTTP %d — not delivered, retrying",
|
|
|
+ job.endpoint, (int) message.status_code);
|
|
|
+ return SendOutcome.RETRY;
|
|
|
+ }
|
|
|
return SendOutcome.DELIVERED;
|
|
|
}
|
|
|
|
|
|
@@ -684,7 +806,12 @@ namespace ClankerSaddle.Services {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- /** True when the user holds any subscription (the bell's state). */
|
|
|
+ /**
|
|
|
+ * True when the user holds any subscription. Convenience only
|
|
|
+ * since the per-browser bell: the authoritative per-browser
|
|
|
+ * state is {@link user_endpoint_fingerprints} matched
|
|
|
+ * client-side against the browser's own subscription.
|
|
|
+ */
|
|
|
public async bool user_subscribed(OrmSession db, int64 user_id) throws Error {
|
|
|
foreach (var row in yield db.query<PushSubscription>()
|
|
|
.where(expr("user_id == $0", elem<int64?>(user_id)))
|
|
|
@@ -694,6 +821,47 @@ namespace ClankerSaddle.Services {
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * This user's subscription endpoint fingerprints, newest
|
|
|
+ * first (the per-browser bell state): each entry is
|
|
|
+ * {@link endpoint_fingerprint} of one stored endpoint — never
|
|
|
+ * the endpoint itself, which stays server-private. The browser
|
|
|
+ * fingerprints its own pushManager subscription and looks it
|
|
|
+ * up in the list: present => THIS browser is subscribed (show
|
|
|
+ * "Turn off"), absent => "Enable" (+ a count of the others).
|
|
|
+ */
|
|
|
+ public async string[] user_endpoint_fingerprints(OrmSession db, int64 user_id) throws Error {
|
|
|
+ var fingerprints = new string[0];
|
|
|
+ foreach (var row in yield db.query<PushSubscription>()
|
|
|
+ .where(expr("user_id == $0", elem<int64?>(user_id)))
|
|
|
+ .order_by_desc(expr("created_at"))
|
|
|
+ .order_by_desc(expr("id"))
|
|
|
+ .materialise_async()) {
|
|
|
+ fingerprints += endpoint_fingerprint(row.endpoint);
|
|
|
+ }
|
|
|
+ return fingerprints;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * A privacy-safe handle for one endpoint: the first 16 hex
|
|
|
+ * chars of its SHA-256 — enough for a browser to recognise its
|
|
|
+ * own subscription, useless for reconstructing the URL. The
|
|
|
+ * client mirrors this with crypto.subtle (both need a secure
|
|
|
+ * context, so any browser that can subscribe can compare).
|
|
|
+ */
|
|
|
+ public static string endpoint_fingerprint(string endpoint) {
|
|
|
+ var checksum = new Checksum(ChecksumType.SHA256);
|
|
|
+ size_t digest_len = 32;
|
|
|
+ var digest = new uint8[32];
|
|
|
+ checksum.update(endpoint.data, endpoint.length);
|
|
|
+ checksum.get_digest(digest, ref digest_len);
|
|
|
+ var builder = new StringBuilder();
|
|
|
+ for (var index = 0; index < 8; index++) {
|
|
|
+ builder.append_printf("%02x", digest[index]);
|
|
|
+ }
|
|
|
+ return builder.str;
|
|
|
+ }
|
|
|
+
|
|
|
/** True when the user holds a "Notify me" pref for the task. */
|
|
|
public async bool has_notify_pref(OrmSession db, int64 user_id,
|
|
|
int64 task_id) throws Error {
|