diff --git a/backend/routes.js b/backend/routes.js index b61a06c8a..387533f69 100644 --- a/backend/routes.js +++ b/backend/routes.js @@ -287,7 +287,8 @@ route.GET('/latestHEADinfo/{contractID}', { }) route.GET('/time', {}, function (request, h) { - return new Date().toISOString() + return h.response(new Date().toISOString()) + .header('Cache-Control', 'no-store') }) // TODO: if the browser deletes our cache then not everyone @@ -713,9 +714,13 @@ route.GET('/assets/{subpath*}', { .etag(basename) .header('Cache-Control', 'public,max-age=31536000,immutable') } - // Files like `main.js` or `main.css` should be revalidated before use. Se we use the default headers. + // Files like `main.js` or `main.css` should be revalidated before use. + // We set a short 'stale-while-revalidate' value instead of 'no-cache' to + // signal to the app that it's fine to use old versions when offline or over + // unreliable connections. // This should also be suitable for serving unversioned fonts and images. return h.file(subpath) + .header('Cache-Control', 'public,max-age=604800,stale-while-revalidate=86400') }) route.GET(staticServeConfig.routePath, {}, { @@ -804,7 +809,13 @@ route.GET('/zkpp/{name}/auth_hash', { try { const challenge = await getChallenge(req.params['name'], req.query['b']) - return challenge || Boom.notFound() + if (!challenge) { + return Boom.notFound() + } + + return h.response(challenge) + .header('Cache-Control', 'no-store') + .header('Content-Type', 'text/plain') } catch (e) { e.ip = req.headers['x-real-ip'] || req.info.remoteAddress console.error(e, 'Error at GET /zkpp/{name}/auth_hash: ' + e.message) @@ -828,7 +839,9 @@ route.GET('/zkpp/{name}/contract_hash', { const salt = await getContractSalt(req.params['name'], req.query['r'], req.query['s'], req.query['sig'], req.query['hc']) if (salt) { - return salt + return h.response(salt) + .header('Cache-Control', 'no-store') + .header('Content-Type', 'text/plain') } } catch (e) { e.ip = req.headers['x-real-ip'] || req.info.remoteAddress @@ -854,7 +867,8 @@ route.POST('/zkpp/{name}/updatePasswordHash', { const result = await updateContractSalt(req.params['name'], req.payload['r'], req.payload['s'], req.payload['sig'], req.payload['hc'], req.payload['Ea']) if (result) { - return result + return h.response(result) + .header('Content-type', 'text/plain') } } catch (e) { e.ip = req.headers['x-real-ip'] || req.info.remoteAddress diff --git a/frontend/controller/actions/group.js b/frontend/controller/actions/group.js index 3e56857e5..87cd10da7 100644 --- a/frontend/controller/actions/group.js +++ b/frontend/controller/actions/group.js @@ -380,7 +380,7 @@ export default (sbp('sbp/selectors/register', { removeEventHandler() removeLogoutHandler() // The event handler recursively calls this same selector - // A different path should be taken, since te event handler + // A different path should be taken, since the event handler // should be called after the key request has been answered // and processed sbp('gi.actions/group/join', params).catch((e) => { diff --git a/frontend/controller/app/identity.js b/frontend/controller/app/identity.js index 6b9cacf35..374668aa7 100644 --- a/frontend/controller/app/identity.js +++ b/frontend/controller/app/identity.js @@ -388,7 +388,9 @@ export default (sbp('sbp/selectors/register', { }) } else { try { - await sbp('chelonia/contract/sync', identityContractID) + if (navigator.onLine !== false) { + await sbp('chelonia/contract/sync', identityContractID) + } } catch (e) { // Since we're throwing or returning, the `await` below will not // be used. In either case, login won't complete after this point, diff --git a/frontend/controller/service-worker.js b/frontend/controller/service-worker.js index afef5b8d6..b073d59b6 100644 --- a/frontend/controller/service-worker.js +++ b/frontend/controller/service-worker.js @@ -162,6 +162,7 @@ sbp('sbp/selectors/register', { // have this function there. However, most examples perform this outside of the // SW, and private testing showed that it's more reliable doing it here. 'service-worker/setup-push-subscription': async function (retryCount?: number) { + if (process.env.CI || window.Cypress) return await sbp('okTurtles.eventQueue/queueEvent', 'service-worker/setup-push-subscription', async () => { // Get the installed service-worker registration const registration = await navigator.serviceWorker.ready diff --git a/frontend/controller/serviceworkers/cache.js b/frontend/controller/serviceworkers/cache.js new file mode 100644 index 000000000..214be3fbc --- /dev/null +++ b/frontend/controller/serviceworkers/cache.js @@ -0,0 +1,143 @@ +const CACHE_VERSION = process.env.GI_VERSION +const CURRENT_CACHES = { + assets: `assets-cache_v${CACHE_VERSION}` +} + +if ( + typeof Cache === 'function' && + typeof CacheStorage === 'function' && + typeof caches === 'object' && + (caches instanceof CacheStorage) +) { + const locationUrl = new URL(self.location) + const routerBase = locationUrl.searchParams.get('routerBase') ?? '/app' + + self.addEventListener('install', (event) => { + event.waitUntil( + caches + .open(CURRENT_CACHES.assets) + .then((cache) => + cache.addAll([ + '/assets/pwa-manifest.webmanifest', + '/assets/images/group-income-icon-transparent.png', + '/assets/images/pwa-icons/group-income-icon-maskable_192x192.png', + '/assets/css/main.css', + '/assets/js/main.js', + `${routerBase}/` + ]).catch(e => { + console.error('Error adding initial entries to cache', e) + }) + ) + ) + }, false) + + // Taken from the MDN example: + // + self.addEventListener('activate', (event) => { + const expectedCacheNamesSet = new Set(Object.values(CURRENT_CACHES)) + + event.waitUntil( + caches.keys().then((cacheNames) => + Promise.allSettled( + cacheNames.map((cacheName) => { + if (!expectedCacheNamesSet.has(cacheName)) { + // If this cache name isn't present in the set of + // "expected" cache names, then delete it. + console.log('Deleting out of date cache:', cacheName) + return caches.delete(cacheName) + } + + return undefined + }) + ) + ) + ) + }, false) + + self.addEventListener('fetch', function (event) { + console.debug(`[sw] fetch : ${event.request.method} - ${event.request.url}`) + + if (!['GET', 'HEAD', 'OPTIONS'].includes(event.request.method)) { + return + } + + try { + const url = new URL(event.request.url) + + if (url.origin !== self.location.origin) { + return + } + + if ( + ['/eventsAfter/', '/name/', '/latestHEADinfo/', '/file/', '/kv/', '/zkpp/'].some(prefix => url.pathname.startsWith(prefix)) || + url.pathname === '/time' + ) { + return + } + + // If the route starts with `${routerBase}/` or `/`, redirect to + // `${routerBase}/`, since the HTML content is presumed to be the same. + // This is _crucial_ for the offline PWA to work, since currently the app + // uses different paths. + if ( + url.pathname === '/' || + (url.pathname.startsWith(`${routerBase}/`) && url.pathname !== `${routerBase}/`) + ) { + event.respondWith(Response.redirect(`${routerBase}/`, 302)) + return + } + } catch (e) { + return + } + + event.respondWith( + caches.open(CURRENT_CACHES.assets).then((cache) => { + return cache + .match(event.request, { ignoreSearch: true, ignoreVary: true }) + .then((cachedResponse) => { + if (cachedResponse) { + // If we're offline, return the cached response, if it exists + if (navigator.onLine === false) { + return cachedResponse + } + } + + // We use the original `event.request` for the network fetch + // instead of the possibly re-written `request` (used as a cache + // key) because the re-written request makes tests fail. + return fetch(event.request).then(async (response) => { + if ( + // Save successful reponses + response.status >= 200 && + response.status < 400 && + response.status !== 206 && // Partial response + response.status !== 304 && // Not modified + // Which don't have a 'no-store' directive + !response.headers.get('cache-control')?.split(',').some(x => x.trim() === 'no-store') + ) { + await cache.put(event.request, response.clone()).catch(e => { + console.error('Error adding request to cache') + }) + } else if (response.status < 500) { + // For 5xx responses (server errors, we don't delete the cache + // entry. This is so that, in the event of a 5xx error, + // the offline app still works.) + await cache.delete(event.request) + } + + return response + }).catch(e => { + if (cachedResponse) { + console.warn('Error while fetching', event.request, e) + // If there was a network error fetching, return the cached + // response, if it exists + return cachedResponse + } + + throw e + }) + }) + }) + ) + }, false) +} diff --git a/frontend/controller/serviceworkers/sw-primary.js b/frontend/controller/serviceworkers/sw-primary.js index f707346d1..9d2ff7730 100644 --- a/frontend/controller/serviceworkers/sw-primary.js +++ b/frontend/controller/serviceworkers/sw-primary.js @@ -29,6 +29,7 @@ import { NEW_UNREAD_MESSAGES, NOTIFICATION_EMITTED, NOTIFICATION_REMOVED, NOTIFICATION_STATUS_LOADED, OFFLINE, ONLINE, SERIOUS_ERROR, SWITCH_GROUP } from '../../utils/events.js' +import './cache.js' import './push.js' import './sw-namespace.js' @@ -272,10 +273,6 @@ self.addEventListener('activate', function (event) { event.waitUntil(setupPromise.then(() => self.clients.claim())) }) -self.addEventListener('fetch', function (event) { - console.debug(`[sw] fetch : ${event.request.method} - ${event.request.url}`) -}) - // TODO: this doesn't persist data across browser restarts, so try to use // the cache instead, or just localstorage. Investigate whether the service worker // has the ability to access and clear the localstorage periodically.