rastrillo / aviso Public

Clone
git clone https://amadan.net/rastrillo/aviso

Plain git — no account needed to clone.

Download

Download this file

1// Service-worker half of aviso, a classic script the app's own sw.js
2// loads with importScripts. It owns nothing about the worker's
3// lifecycle — no skipWaiting, no clients.claim, no listeners of its
4// own; the app attaches these handlers inside its listeners and passes
5// the result to event.waitUntil.
6(function (root) {
7 "use strict";
8
9 function toBytes(base64url) {
10 const pad = "=".repeat((4 - (base64url.length % 4)) % 4);
11 const b64 = (base64url + pad).replace(/-/g, "+").replace(/_/g, "/");
12 const raw = atob(b64);
13 const out = new Uint8Array(raw.length);
14 for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
15 return out;
16 }
17
18 function toBase64url(buf) {
19 let s = "";
20 const bytes = new Uint8Array(buf);
21 for (const b of bytes) s += String.fromCharCode(b);
22 return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
23 }
24
25 // validateURL admits only a root-relative path on the app's own
26 // origin: no scheme, no protocol-relative "//", no backslashes, and
27 // the resolved URL must still be on origin. Applied to the default
28 // decoder, custom decoder output and fallbackURL alike — a click
29 // must never navigate off the app, and an absolute URL is refused
30 // even on-origin so the rule has no exceptions to reason about.
31 function validateURL(raw, origin) {
32 if (typeof raw !== "string" || raw === "") return null;
33 if (raw[0] !== "/" || raw[1] === "/" || raw.indexOf("\\") !== -1) return null;
34 let u;
35 try { u = new URL(raw, origin); } catch (e) { return null; }
36 if (u.origin !== origin) return null;
37 return u.href;
38 }
39
40 // storedURL re-validates what a previous handlePush put in
41 // notification.data — validateURL's absolute form — by reducing it
42 // to its path and running the same rule. Anything else is refused.
43 function storedURL(raw, origin) {
44 if (typeof raw !== "string" || raw.indexOf(origin + "/") !== 0) return null;
45 return validateURL(raw.slice(origin.length), origin);
46 }
47
48 // decodeDefault reads {title, body, url, tag}; title is required.
49 function decodeDefault(event, origin) {
50 if (!event.data) return null;
51 let p;
52 try { p = event.data.json(); } catch (e) { return null; }
53 if (!p || typeof p.title !== "string" || p.title === "") return null;
54 const options = { data: {} };
55 if (typeof p.body === "string") options.body = p.body;
56 if (typeof p.tag === "string") options.tag = p.tag;
57 // Kept root-relative here; show() is the one place that validates
58 // and resolves, for this decoder and custom ones alike.
59 if (typeof p.url === "string") options.data.url = p.url;
60 return { title: p.title, options };
61 }
62
63 // handlePush always ends in a visible notification: WebKit revokes
64 // push for a worker that receives without showing, so a payload the
65 // decoder cannot read shows the app's fallback rather than nothing.
66 async function handlePush(event, opts) {
67 if (!opts || typeof opts.fallback !== "function") {
68 throw new Error("aviso: fallback is required");
69 }
70 const origin = root.location.origin;
71 let n = null;
72 try {
73 n = opts.decode ? await opts.decode(event) : decodeDefault(event, origin);
74 } catch (e) { n = null; }
75 if (usable(n)) {
76 try {
77 return await show(n, origin);
78 } catch (e) {
79 // A decoder can hand back options the platform refuses —
80 // uncloneable data, an invalid combination — and that must
81 // still end in a notification.
82 }
83 }
84 return show(await opts.fallback(event), origin);
85 }
86
87 function underKey(sub, key) {
88 const k = sub.options && sub.options.applicationServerKey;
89 return !!k && toBase64url(k) === key;
90 }
91
92 function usable(n) {
93 return !!n && typeof n.title === "string" && n.title !== "";
94 }
95
96 function show(n, origin) {
97 const options = Object.assign({}, n.options || {});
98 options.data = Object.assign({}, options.data || {});
99 if (options.data.url !== undefined) {
100 const href = validateURL(options.data.url, origin);
101 if (href) options.data.url = href; else delete options.data.url;
102 }
103 return root.registration.showNotification(n.title, options);
104 }
105
106 // handleClick closes the notification, focuses a window already at
107 // the destination, else opens it, else the fallback.
108 async function handleClick(event, opts) {
109 const origin = root.location.origin;
110 event.notification.close();
111 const data = event.notification.data || {};
112 let href = data.url ? storedURL(data.url, origin) : null;
113 if (!href && opts && opts.fallbackURL) href = validateURL(opts.fallbackURL, origin);
114 if (!href) return;
115 const all = await root.clients.matchAll({ type: "window", includeUncontrolled: true });
116 for (const c of all) {
117 if (c.url === href && "focus" in c) return c.focus();
118 }
119 return root.clients.openWindow(href);
120 }
121
122 // handleSubscriptionChange renews and saves. A worker forgets its
123 // variables when it is terminated, so the key is fetched on demand
124 // through `publicKey()`; the helper subscribes and builds the full
125 // Subscribe body itself, and the app's `save(body)` only posts.
126 // Saving can fail — an installed app with no live session — and then
127 // it fails silently: reconcile() repairs on the next page open after
128 // sign-in. Nothing here retries and nothing here prompts.
129 async function handleSubscriptionChange(event, opts) {
130 if (!opts || typeof opts.publicKey !== "function" || typeof opts.save !== "function") {
131 throw new Error("aviso: publicKey() and save() are required");
132 }
133 let key;
134 try { key = await opts.publicKey(); } catch (e) { return false; }
135 if (typeof key !== "string" || key === "") return false;
136 // The browser's replacement subscription reuses the old options,
137 // so after a server key rotation it is signed for a key the server
138 // no longer holds: saved under the fetched key it would look valid
139 // and never receive anything. Replace it rather than label it.
140 let sub = event.newSubscription || null;
141 if (sub && !underKey(sub, key)) {
142 try { await sub.unsubscribe(); } catch (e) { /* replaced below either way */ }
143 sub = null;
144 }
145 if (!sub) {
146 try {
147 sub = await root.registration.pushManager.subscribe({
148 userVisibleOnly: true,
149 applicationServerKey: toBytes(key),
150 });
151 } catch (e) { sub = null; }
152 }
153 if (!sub) return false;
154 const json = sub.toJSON();
155 const body = { subscription: { endpoint: json.endpoint, keys: json.keys }, publicKey: key };
156 const old = event.oldSubscription;
157 if (old && old.endpoint && old.endpoint !== json.endpoint) body.previousEndpoint = old.endpoint;
158 try {
159 const resp = await opts.save(body);
160 return !(resp && typeof resp === "object" && resp.ok === false);
161 } catch (e) {
162 return false;
163 }
164 }
165
166 root.AvisoSW = { handlePush, handleClick, handleSubscriptionChange, validateURL };
167})(typeof self !== "undefined" ? self : globalThis);
168