Skip to content
quarry Artifact [107a07c8cf]
Login

Artifact [107a07c8cf]

Artifact 107a07c8cf069b2975a87c3adf2967686a691cbe485009ed36583f893a24c44e:


// 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.
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.
      })
  );
});