// quarry's service worker: the part of RFC 0011 §6 that runs when no
// tab is open. Three jobs, no framework, no build step.
//
// Scope is the whole site, which is why it must be served from the
// root with Service-Worker-Allowed: / (see ServiceWorker in this
// package) — a worker served from a subdirectory can only claim that
// subdirectory, and a notification click could then not focus a page.
'use strict';
// sameOrigin resolves a notification's url against this worker's own
// origin and returns it ONLY if it stayed there.
//
// The url arrives from outside quarry: it is whatever the workflow's
// data_json carried, which came from a payload. Handing it to
// openWindow unchecked makes every push an open redirect — a
// notification that looks like quarry and lands on somebody else's
// login form. Origins are compared with the URL parser, not by looking
// for a substring: "https://evil.test/r/alpha" contains "/r/alpha".
//
// Returns null for anything foreign, unparseable, or non-http.
function sameOrigin(raw) {
try {
var u = new URL(String(raw), self.location.href);
if (u.origin !== self.location.origin) {
return null;
}
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
return null;
}
return u.href;
} catch (e) {
return null;
}
}
// A push arrives as JSON from notify's webpush provider: the workflow's
// rendered title and body, plus whatever the step's data carried. The
// url is what quarry-side workflows put in data_json (workflows.go).
self.addEventListener('push', function (event) {
var data = {};
if (event.data) {
try {
data = event.data.json();
} catch (e) {
// A provider that sends plain text is not a reason to show
// nothing: a notification with no title at all is worse.
data = { body: event.data.text() };
}
}
var body = data.body || data.content || '';
// A foreign url is dropped rather than shown: the notification is
// still worth delivering, it just goes to the inbox instead.
var url = sameOrigin((data.data && data.data.url) || data.url || '/') || sameOrigin('/');
event.waitUntil(
self.registration.showNotification(data.title || data.subject || 'quarry', {
body: body,
data: { url: url },
// Tagged by destination so a second notification about the same
// thing replaces the first rather than stacking: an inbox that
// buzzes twice for one reply is an inbox people silence.
tag: url,
})
);
});
// A click goes to the thing the notification is about — focusing a tab
// already showing it rather than opening a second copy.
self.addEventListener('notificationclick', function (event) {
event.notification.close();
var url = sameOrigin((event.notification.data && event.notification.data.url) || '/');
if (!url) {
return;
}
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function (list) {
for (var i = 0; i < list.length; i++) {
var client = list[i];
// Whole-URL equality, both sides normalised by the parser: a
// substring match would focus any tab whose address happened
// to contain this one.
if (sameOrigin(client.url) === url && 'focus' in client) {
return client.focus();
}
}
if (self.clients.openWindow) {
return self.clients.openWindow(url);
}
return undefined;
})
);
});
// A push service may rotate a subscription without asking. Re-subscribe
// with the same application key and tell quarry the new endpoint;
// without this the device goes quiet and nobody finds out why.
//
// These are the WEB routes rather than the API plane's, because a service
// worker has no credentials to offer: the API plane authenticates by
// HTTP Basic only (internal/api/router.go), so a worker's fetch there
// is answered 401 and the device stays quiet — the exact failure this
// handler exists to prevent. The cookie session the browser already
// holds is what `credentials: 'same-origin'` sends.
//
// TODO(RFC 0011 Phase 3b): /me/push and /me/push/key are the web
// routes Phase 3b adds in internal/web. Until they land this handler
// re-subscribes and the POST 404s, which is the same silence as today
// and no worse; it starts working the moment the routes exist.
self.addEventListener('pushsubscriptionchange', function (event) {
event.waitUntil(
fetch('/me/push/key', { credentials: 'same-origin' })
.then(function (r) {
return r.json();
})
.then(function (k) {
if (!k.publicKey) {
return undefined;
}
return self.registration.pushManager
.subscribe({ userVisibleOnly: true, applicationServerKey: k.publicKey })
.then(function (sub) {
return fetch('/me/push', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sub),
});
});
})
.catch(function () {
// Best-effort by construction: a worker that throws here is a
// worker the browser may stop running altogether.
})
);
});