From 17f3e774420485d88b169e8084beacca2184caf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= <3857362+corrideat@users.noreply.github.com> Date: Sat, 19 Oct 2024 13:26:23 +0000 Subject: [PATCH] SW logs --- frontend/controller/actions/chatroom.js | 8 +- frontend/controller/actions/identity.js | 9 ++ frontend/controller/service-worker.js | 11 ++- .../controller/serviceworkers/sw-primary.js | 41 +++++++-- frontend/main.js | 4 + frontend/model/captureLogs.js | 35 +++----- frontend/model/database.js | 26 +++++- frontend/model/logServer.js | 28 ++++++ frontend/model/logger.js | 1 - frontend/model/settings/vuexModule.js | 2 +- frontend/model/swCaptureLogs.js | 87 +++++++++++++++++++ frontend/setupChelonia.js | 6 +- 12 files changed, 216 insertions(+), 42 deletions(-) create mode 100644 frontend/model/logServer.js create mode 100644 frontend/model/swCaptureLogs.js diff --git a/frontend/controller/actions/chatroom.js b/frontend/controller/actions/chatroom.js index 697f34b47b..3b50f347c3 100644 --- a/frontend/controller/actions/chatroom.js +++ b/frontend/controller/actions/chatroom.js @@ -22,15 +22,15 @@ sbp('okTurtles.events/on', MESSAGE_RECEIVE_RAW, ({ newMessage }) => { const state = sbp('chelonia/contract/state', contractID) - const getters = sbp('state/vuex/getters') - const mentions = makeMentionFromUserID(getters.ourIdentityContractId) + const rootState = sbp('chelonia/rootState') + const mentions = makeMentionFromUserID(rootState.loggedIn?.identityContractID) const msgData = newMessage || data const isMentionedMe = (!!newMessage || data.type === MESSAGE_TYPES.TEXT) && msgData.text && (msgData.text.includes(mentions.me) || msgData.text.includes(mentions.all)) if (!newMessage) { - const isAlreadyAdded = !!getters - .chatRoomUnreadMessages(contractID).find(m => m.messageHash === data.hash) + // TODO: rootState.unreadMessages for SW + const isAlreadyAdded = rootState.unreadMessages?.[contractID]?.unreadMessages.find(m => m.messageHash === data.hash) if (isAlreadyAdded && !isMentionedMe) { sbp('gi.actions/identity/kv/removeChatRoomUnreadMessage', { contractID, messageHash: data.hash }) diff --git a/frontend/controller/actions/identity.js b/frontend/controller/actions/identity.js index b3b9098bae..89c686dc4b 100644 --- a/frontend/controller/actions/identity.js +++ b/frontend/controller/actions/identity.js @@ -245,6 +245,10 @@ export default (sbp('sbp/selectors/register', { console.debug('[gi.actions/identity/login] Scheduled call starting', identityContractID) transientSecretKeys = transientSecretKeys.map(k => ({ key: deserializeKey(k.valueOf()), transient: true })) + // If running in a SW, start log capture here + if (typeof WorkerGlobalScope === 'function') { + await sbp('swLogs/startCapture', identityContractID) + } await sbp('chelonia/reset', { ...cheloniaState, loggedIn: { identityContractID } }) await sbp('chelonia/storeSecretKeys', new Secret(transientSecretKeys)) @@ -404,6 +408,11 @@ export default (sbp('sbp/selectors/register', { } // Clear the file cache when logging out to preserve privacy sbp('gi.db/filesCache/clear').catch((e) => { console.error('Error clearing file cache', e) }) + // If running inside a SW, clear logs + if (typeof WorkerGlobalScope === 'function') { + // clear stored logs to prevent someone else accessing sensitve data + sbp('swLogs/pauseCapture', { wipeOut: true }).catch((e) => { console.error('Error clearing file cache', e) }) + } sbp('okTurtles.events/emit', LOGOUT) return cheloniaState }, diff --git a/frontend/controller/service-worker.js b/frontend/controller/service-worker.js index 8a3d594e1b..8cf004cb1c 100644 --- a/frontend/controller/service-worker.js +++ b/frontend/controller/service-worker.js @@ -2,7 +2,7 @@ import { PUBSUB_INSTANCE } from '@controller/instance-keys.js' import sbp from '@sbp/sbp' -import { PWA_INSTALLABLE } from '@utils/events.js' +import { LOGIN_COMPLETE, PWA_INSTALLABLE, SET_APP_LOGS_FILTER } from '@utils/events.js' import { HOURS_MILLIS } from '~/frontend/model/contracts/shared/time.js' import { PUBSUB_RECONNECTION_SUCCEEDED, PUSH_SERVER_ACTION_TYPE, REQUEST_TYPE, createMessage } from '~/shared/pubsub.js' import { deserializer } from '~/shared/serdes/index.js' @@ -253,7 +253,14 @@ sbp('sbp/selectors/register', { const result = await pwa.deferredInstallPrompt.prompt() return result.outcome } -}) +}); + +// Events that need to be relayed to the SW +[LOGIN_COMPLETE, SET_APP_LOGS_FILTER].forEach((event) => + sbp('okTurtles.events/on', event, (...data) => { + navigator.serviceWorker.controller?.postMessage({ type: 'event', subtype: event, data }) + }) +) // helper method diff --git a/frontend/controller/serviceworkers/sw-primary.js b/frontend/controller/serviceworkers/sw-primary.js index cf80e380e7..3647613a3c 100644 --- a/frontend/controller/serviceworkers/sw-primary.js +++ b/frontend/controller/serviceworkers/sw-primary.js @@ -1,6 +1,7 @@ 'use strict' import { PROPOSAL_ARCHIVED } from '@model/contracts/shared/constants.js' +import '@model/swCaptureLogs.js' import '@sbp/okturtles.data' import '@sbp/okturtles.eventqueue' import '@sbp/okturtles.events' @@ -27,8 +28,28 @@ deserializer.register(Secret) // https://frontendian.co/service-workers // https://stackoverflow.com/a/49748437 => https://medium.com/@nekrtemplar/self-destroying-serviceworker-73d62921d717 => https://love2dev.com/blog/how-to-uninstall-a-service-worker/ +const reducer = (o, v) => { o[v] = true; return o } +// Domains for which debug logging won't be enabled. +const domainBlacklist = [ + 'sbp', + 'okTurtles.data' +].reduce(reducer, {}) +// Selectors for which debug logging won't be enabled. +const selectorBlacklist = [ + 'chelonia/db/get', + 'chelonia/db/set', + 'chelonia/rootState', + 'chelonia/haveSecretKey', + 'chelonia/private/enqueuePostSyncOps', + 'chelonia/private/invoke', + 'state/vuex/state', + 'state/vuex/getters', + 'state/vuex/settings', + 'gi.db/settings/save', + 'gi.db/logs/save' +].reduce(reducer, {}) sbp('sbp/filters/global/add', (domain, selector, data) => { - // if (domainBlacklist[domain] || selectorBlacklist[selector]) return + if (domainBlacklist[domain] || selectorBlacklist[selector]) return console.debug(`[sw] [sbp] ${selector}`, data) }); @@ -50,10 +71,7 @@ sbp('sbp/filters/global/add', (domain, selector, data) => { }) sbp('sbp/selectors/register', { - 'state/vuex/state': () => { - // TODO: Remove this selector once it's removed from contracts - return sbp('chelonia/rootState') - }, + 'state/vuex/state': () => sbp('chelonia/rootState'), 'state/vuex/getters': () => { const obj = Object.create(null) Object.defineProperties(obj, Object.fromEntries(Object.entries(getters).map(([getter, fn]: [string, Function]) => { @@ -83,11 +101,11 @@ sbp('sbp/selectors/register', { }) sbp('sbp/selectors/register', { - // TODO: Implement this (and some other logs-related selectors, such as for - // starting capture, pausing capture) - 'appLogs/save': () => Promise.resolve(undefined) + 'appLogs/save': () => sbp('swLogs/save') }) +const setupPromise = setupChelonia() + self.addEventListener('install', function (event) { console.debug('[sw] install') event.waitUntil(self.skipWaiting()) @@ -97,7 +115,7 @@ self.addEventListener('activate', function (event) { console.debug('[sw] activate') // 'clients.claim()' reference: https://web.dev/articles/service-worker-lifecycle#clientsclaim - event.waitUntil(setupChelonia().then(() => self.clients.claim())) + event.waitUntil(setupPromise.then(() => self.clients.claim())) }) self.addEventListener('fetch', function (event) { @@ -163,6 +181,11 @@ self.addEventListener('message', function (event) { clients.forEach(client => client.navigate(client.url)) }) break + case 'event': + console.error('@@@SW EVENT RECEIVED', event.data.subtype, ...deserializer(event.data.data)) + // TODO: UNCOMMENT + // // sbp('okTurtles.events/emit', event.data.subtype, ...deserializer(event.data.data)) + break default: console.error('[sw] unknown message type:', event.data) break diff --git a/frontend/main.js b/frontend/main.js index 770b6a6dcd..33b68ab41f 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -212,12 +212,16 @@ async function startApp () { }) sbp('sbp/selectors/register', { 'sw-namespace/*': (...args) => { + // Remove the `sw-` prefix from the selector return swRpc(args[0].slice(3), ...args.slice(1)) } }) sbp('sbp/selectors/register', { 'gi.notifications/*': swRpc }) + sbp('sbp/selectors/register', { + 'swLogs/*': swRpc + }) /* eslint-disable no-new */ new Vue({ diff --git a/frontend/model/captureLogs.js b/frontend/model/captureLogs.js index 8bf2ddeace..c128c052f8 100644 --- a/frontend/model/captureLogs.js +++ b/frontend/model/captureLogs.js @@ -3,6 +3,7 @@ import { SET_APP_LOGS_FILTER } from '~/frontend/utils/events.js' import { MAX_LOG_ENTRIES } from '~/frontend/utils/constants.js' import { L } from '@common/common.js' import { createLogger } from './logger.js' +import logServer from './logServer.js' /* - giConsole/[username]/entries - the stored log entries. @@ -18,7 +19,7 @@ const originalConsole = self.console let logger: Object = null let identityContractID: string = '' -// A default storage backend using `localStorage`. +// A default storage backend using `sessionStorage`. const getItem = (key: string): ?string => sessionStorage.getItem(`giConsole/${identityContractID}/${key}`) const removeItem = (key: string): void => sessionStorage.removeItem(`giConsole/${identityContractID}/${key}`) const setItem = (key: string, value: any): void => { @@ -46,7 +47,7 @@ async function captureLogsStart (userLogged: string) { // NEW_VISIT -> The user comes from an ongoing session (refresh or login). const isNewSession = !sessionStorage.getItem('NEW_SESSION') if (isNewSession) { sessionStorage.setItem('NEW_SESSION', '1') } - console.log(isNewSession ? 'NEW_SESSION' : 'NEW_VISIT', 'Starting to capture logs of type:', logger.appLogsFilter) + originalConsole.log(isNewSession ? 'NEW_SESSION' : 'NEW_VISIT', 'Starting to capture logs of type:', logger.appLogsFilter) } async function captureLogsPause ({ wipeOut }: { wipeOut: boolean }): Promise { @@ -92,11 +93,18 @@ function downloadOrShareLogs (actionType: 'share' | 'download', elLink?: HTMLAnc } } +// The reason to use the 'visibilitychange' event over the 'beforeunload' event +// is that the latter is unreliable on mobile. For example, if a tab is set to +// the background and then closed, the 'beforeunload' event may never be fired. +// Furthermore, 'beforeunload' has implications for how the bfcache works. window.addEventListener('visibilitychange', event => sbp('appLogs/save').catch(e => { console.error('Error saving logs during visibilitychange event handler', e) })) -sbp('sbp/selectors/register', { +// Enable logging to the server +logServer(originalConsole) + +export default (sbp('sbp/selectors/register', { 'appLogs/downloadOrShare': downloadOrShareLogs, 'appLogs/get' () { return logger?.entries.toArray() ?? [] }, async 'appLogs/save' () { await logger?.save() }, @@ -107,22 +115,5 @@ sbp('sbp/selectors/register', { identityContractID = userID try { await clearLogs() } catch {} identityContractID = savedID - }, - // only log to server if we're in development mode and connected over the tunnel (which creates URLs that - // begin with 'https://gi' per Gruntfile.js) - 'appLogs/logServer': process.env.NODE_ENV !== 'development' || !window.location.href.startsWith('https://gi') - ? () => {} - : function (level, stringifyMe) { - if (level === 'debug') return // comment out to send much more log info - const value = JSON.stringify(stringifyMe) - fetch(`${sbp('okTurtles.data/get', 'API_URL')}/log`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ level, value }) - }).catch(e => { - originalConsole.error(`[captureLogs] '${e.message}' attempting to log [${level}] to server:`, value) - }) - } -}) + } +}): string[]) diff --git a/frontend/model/database.js b/frontend/model/database.js index 0b3aa38f8b..0ef432bd2b 100644 --- a/frontend/model/database.js +++ b/frontend/model/database.js @@ -360,7 +360,7 @@ sbp('sbp/selectors/register', { }) // ====================================== -// Archve for proposals and anything else +// Archive for proposals and anything else // ====================================== const archive = localforage.createInstance({ @@ -382,3 +382,27 @@ sbp('sbp/selectors/register', { return archive.clear() } }) + +// ====================================== +// Application logs, used for the SW logs +// ====================================== + +const logs = localforage.createInstance({ + name: 'Group Income', + storeName: 'Logs' +}) + +sbp('sbp/selectors/register', { + 'gi.db/logs/save': function (key: string, value: any): Promise<*> { + return logs.setItem(key, value) + }, + 'gi.db/logs/load': function (key: string): Promise { + return logs.getItem(key) + }, + 'gi.db/logs/delete': function (key: string): Promise { + return logs.removeItem(key) + }, + 'gi.db/logs/clear': function (): Promise { + return logs.clear() + } +}) diff --git a/frontend/model/logServer.js b/frontend/model/logServer.js new file mode 100644 index 0000000000..70a430635e --- /dev/null +++ b/frontend/model/logServer.js @@ -0,0 +1,28 @@ +import sbp from '@sbp/sbp' +import '@sbp/okturtles.events' +import { CAPTURED_LOGS } from '~/frontend/utils/events.js' + +export default (console: Object): Function => { + // only log to server if we're in development mode and connected over the + // tunnel (which creates URLs that begin with 'https://gi' per Gruntfile.js) + if (process.env.NODE_ENV !== 'development' || !self.location.href.startsWith('https://gi')) return + + sbp('okTurtles.events/on', CAPTURED_LOGS, ({ level, msg: stringifyMe }) => { + if (level === 'debug') return // comment out to send much more log info + const value = JSON.stringify(stringifyMe) + // To avoid infinite loop because we log all selector calls, we run sbp calls + // here in a roundabout way by getting the function to which they're mapped. + // The reason this works is because the entire `sbp` domain is blacklisted + // from being logged. + const apiUrl = sbp('sbp/selectors/fn', 'okTurtles.data/get')('API_URL') + fetch(`${apiUrl}/log`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ level, value }) + }).catch(e => { + console.error(`[captureLogs] '${e.message}' attempting to log [${level}] to server:`, value) + }) + }) +} diff --git a/frontend/model/logger.js b/frontend/model/logger.js index f14594530b..f65c6dc82b 100644 --- a/frontend/model/logger.js +++ b/frontend/model/logger.js @@ -96,5 +96,4 @@ function captureLogEntry (logger: Object, type: string, ...args) { // The reason this works is because the entire `sbp` domain is blacklisted // from being logged in main.js. sbp('sbp/selectors/fn', 'okTurtles.events/emit')(CAPTURED_LOGS, entry) - sbp('sbp/selectors/fn', 'appLogs/logServer')(type, entry.msg) } diff --git a/frontend/model/settings/vuexModule.js b/frontend/model/settings/vuexModule.js index fd7f1114dc..1f4dc95b05 100644 --- a/frontend/model/settings/vuexModule.js +++ b/frontend/model/settings/vuexModule.js @@ -26,7 +26,7 @@ const defaultTheme = 'system' const defaultColor: string = checkSystemColor() export const defaultSettings = { - appLogsFilter: (((process.env.NODE_ENV === 'development' || new URLSearchParams(window.location.search).get('debug')) + appLogsFilter: (((process.env.NODE_ENV === 'development' || new URLSearchParams(location.search).get('debug')) ? ['error', 'warn', 'info', 'debug', 'log'] : ['error', 'warn', 'info']): string[]), fontSize: 16, diff --git a/frontend/model/swCaptureLogs.js b/frontend/model/swCaptureLogs.js new file mode 100644 index 0000000000..fdacdd7d85 --- /dev/null +++ b/frontend/model/swCaptureLogs.js @@ -0,0 +1,87 @@ +import sbp from '@sbp/sbp' +import { debounce } from '@model/contracts/shared/giLodash.js' +import { CAPTURED_LOGS, SET_APP_LOGS_FILTER } from '~/frontend/utils/events.js' +import { MAX_LOG_ENTRIES } from '~/frontend/utils/constants.js' +import { createLogger } from './logger.js' +import logServer from './logServer.js' + +/* + - giConsole/[username]/entries - the stored log entries. + - giConsole/[username]/config - the logger config used. +*/ + +const config = { + maxEntries: MAX_LOG_ENTRIES +} +const originalConsole = self.console + +// These are initialized in `captureLogsStart()`. +let logger: Object = null +let identityContractID: string = '' + +// A default storage backend using `IndexedDB`. +const getItem = (key: string): Promise => sbp('gi.db/logs/load', `giConsole/${identityContractID}/${key}`) +const removeItem = (key: string): Promise => sbp('gi.db/logs/delete', `giConsole/${identityContractID}/${key}`) +const setItem = (key: string, value: any): Promise => { + return sbp('gi.db/logs/save', `giConsole/${identityContractID}/${key}`, typeof value === 'string' ? value : JSON.stringify(value)) +} + +async function captureLogsStart (userLogged: string) { + identityContractID = userLogged + + logger = await createLogger(config, { getItem, removeItem, setItem }) + + // Save the new config. + await setItem('config', config) + + // TODO: Get this dynamically + logger.setAppLogsFilter((((process.env.NODE_ENV === 'development' || new URLSearchParams(location.search).get('debug')) + ? ['error', 'warn', 'info', 'debug', 'log'] + : ['error', 'warn', 'info']): string[])) + + // Subscribe to `swLogsFilter` changes. + sbp('okTurtles.events/on', SET_APP_LOGS_FILTER, logger.setAppLogsFilter) + + // Overwrite the original console. + self.console = logger.console + + originalConsole.log('Starting to capture logs of type:', logger.swLogsFilter) +} + +async function captureLogsPause ({ wipeOut }: { wipeOut: boolean }): Promise { + if (wipeOut) { await clearLogs() } + sbp('okTurtles.events/off', SET_APP_LOGS_FILTER) + console.log('captureLogs paused') + // Restore original console behavior. + self.console = originalConsole +} + +async function clearLogs () { + await logger?.clear() +} + +// In the SW, there's no event to detect when the SW is about to terminate. As +// a result, we must save logs on every saved entry. However, this wouldn't be +// very performant, so we debounce the save instead. +sbp('okTurtles.events/on', CAPTURED_LOGS, debounce(() => { + logger?.save().catch(e => { + console.error('Error saving logs during CAPTURED_LOGS event handler', e) + }) +}, 1000)) +isNaN(CAPTURED_LOGS, debounce) + +// Enable logging to the server +logServer(originalConsole) + +export default (sbp('sbp/selectors/register', { + 'swLogs/get' () { return logger?.entries.toArray() ?? [] }, + async 'swLogs/save' () { await logger?.save() }, + 'swLogs/pauseCapture': captureLogsPause, + 'swLogs/startCapture': captureLogsStart, + async 'swLogs/clearLogs' (userID) { + const savedID = identityContractID + identityContractID = userID + try { await clearLogs() } catch {} + identityContractID = savedID + } +}): string[]) diff --git a/frontend/setupChelonia.js b/frontend/setupChelonia.js index 5c2033aca5..7752c90bb3 100644 --- a/frontend/setupChelonia.js +++ b/frontend/setupChelonia.js @@ -50,11 +50,15 @@ const setupChelonia = async (): Promise<*> => { // copies are to be considered a cache and are not authoritative. // 5. Vuex state is _not_ copied to Chelonia state (i.e., the copying is // in a single direction: Chelonia -> Vuex) + console.error('@@@SW WILL LOAD STATE') await sbp('gi.db/settings/load', SETTING_CHELONIA_STATE).then(async (cheloniaState) => { + console.error('@@@SW STATE', SETTING_CHELONIA_STATE, cheloniaState) if (!cheloniaState) return const identityContractID = await sbp('gi.db/settings/load', SETTING_CURRENT_USER) if (!identityContractID) return await sbp('chelonia/reset', cheloniaState) + // If there's an active session, we need to start capture now + await sbp('swLogs/startCapture', identityContractID) }) // this is to ensure compatibility between frontend and test/backend.test.js @@ -168,8 +172,6 @@ const setupChelonia = async (): Promise<*> => { } }) - // TODO: This needs to be relayed from the originating tab to the SW. Maybe - // creating a selector would be more appropriate. sbp('okTurtles.events/on', LOGIN_COMPLETE, () => { const state = sbp('chelonia/rootState') if (!state.loggedIn) {