// Service-worker half of aviso, a classic script the app's own sw.js // loads with importScripts. It owns nothing about the worker's // lifecycle — no skipWaiting, no clients.claim, no listeners of its // own; the app attaches these handlers inside its listeners and passes // the result to event.waitUntil. (function (root) { "use strict"; function toBytes(base64url) { const pad = "=".repeat((4 - (base64url.length % 4)) % 4); const b64 = (base64url + pad).replace(/-/g, "+").replace(/_/g, "/"); const raw = atob(b64); const out = new Uint8Array(raw.length); for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); return out; } function toBase64url(buf) { let s = ""; const bytes = new Uint8Array(buf); for (const b of bytes) s += String.fromCharCode(b); return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } // validateURL admits only a root-relative path on the app's own // origin: no scheme, no protocol-relative "//", no backslashes, and // the resolved URL must still be on origin. Applied to the default // decoder, custom decoder output and fallbackURL alike — a click // must never navigate off the app, and an absolute URL is refused // even on-origin so the rule has no exceptions to reason about. function validateURL(raw, origin) { if (typeof raw !== "string" || raw === "") return null; if (raw[0] !== "/" || raw[1] === "/" || raw.indexOf("\\") !== -1) return null; let u; try { u = new URL(raw, origin); } catch (e) { return null; } if (u.origin !== origin) return null; return u.href; } // storedURL re-validates what a previous handlePush put in // notification.data — validateURL's absolute form — by reducing it // to its path and running the same rule. Anything else is refused. function storedURL(raw, origin) { if (typeof raw !== "string" || raw.indexOf(origin + "/") !== 0) return null; return validateURL(raw.slice(origin.length), origin); } // decodeDefault reads {title, body, url, tag}; title is required. function decodeDefault(event, origin) { if (!event.data) return null; let p; try { p = event.data.json(); } catch (e) { return null; } if (!p || typeof p.title !== "string" || p.title === "") return null; const options = { data: {} }; if (typeof p.body === "string") options.body = p.body; if (typeof p.tag === "string") options.tag = p.tag; // Kept root-relative here; show() is the one place that validates // and resolves, for this decoder and custom ones alike. if (typeof p.url === "string") options.data.url = p.url; return { title: p.title, options }; } // handlePush always ends in a visible notification: WebKit revokes // push for a worker that receives without showing, so a payload the // decoder cannot read shows the app's fallback rather than nothing. async function handlePush(event, opts) { if (!opts || typeof opts.fallback !== "function") { throw new Error("aviso: fallback is required"); } const origin = root.location.origin; let n = null; try { n = opts.decode ? await opts.decode(event) : decodeDefault(event, origin); } catch (e) { n = null; } if (usable(n)) { try { return await show(n, origin); } catch (e) { // A decoder can hand back options the platform refuses — // uncloneable data, an invalid combination — and that must // still end in a notification. } } return show(await opts.fallback(event), origin); } function underKey(sub, key) { const k = sub.options && sub.options.applicationServerKey; return !!k && toBase64url(k) === key; } function usable(n) { return !!n && typeof n.title === "string" && n.title !== ""; } function show(n, origin) { const options = Object.assign({}, n.options || {}); options.data = Object.assign({}, options.data || {}); if (options.data.url !== undefined) { const href = validateURL(options.data.url, origin); if (href) options.data.url = href; else delete options.data.url; } return root.registration.showNotification(n.title, options); } // handleClick closes the notification, focuses a window already at // the destination, else opens it, else the fallback. async function handleClick(event, opts) { const origin = root.location.origin; event.notification.close(); const data = event.notification.data || {}; let href = data.url ? storedURL(data.url, origin) : null; if (!href && opts && opts.fallbackURL) href = validateURL(opts.fallbackURL, origin); if (!href) return; const all = await root.clients.matchAll({ type: "window", includeUncontrolled: true }); for (const c of all) { if (c.url === href && "focus" in c) return c.focus(); } return root.clients.openWindow(href); } // handleSubscriptionChange renews and saves. A worker forgets its // variables when it is terminated, so the key is fetched on demand // through `publicKey()`; the helper subscribes and builds the full // Subscribe body itself, and the app's `save(body)` only posts. // Saving can fail — an installed app with no live session — and then // it fails silently: reconcile() repairs on the next page open after // sign-in. Nothing here retries and nothing here prompts. async function handleSubscriptionChange(event, opts) { if (!opts || typeof opts.publicKey !== "function" || typeof opts.save !== "function") { throw new Error("aviso: publicKey() and save() are required"); } let key; try { key = await opts.publicKey(); } catch (e) { return false; } if (typeof key !== "string" || key === "") return false; // The browser's replacement subscription reuses the old options, // so after a server key rotation it is signed for a key the server // no longer holds: saved under the fetched key it would look valid // and never receive anything. Replace it rather than label it. let sub = event.newSubscription || null; if (sub && !underKey(sub, key)) { try { await sub.unsubscribe(); } catch (e) { /* replaced below either way */ } sub = null; } if (!sub) { try { sub = await root.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: toBytes(key), }); } catch (e) { sub = null; } } if (!sub) return false; const json = sub.toJSON(); const body = { subscription: { endpoint: json.endpoint, keys: json.keys }, publicKey: key }; const old = event.oldSubscription; if (old && old.endpoint && old.endpoint !== json.endpoint) body.previousEndpoint = old.endpoint; try { const resp = await opts.save(body); return !(resp && typeof resp === "object" && resp.ok === false); } catch (e) { return false; } } root.AvisoSW = { handlePush, handleClick, handleSubscriptionChange, validateURL }; })(typeof self !== "undefined" ? self : globalThis);