From 47b551f8872d6a694d81b5d23c99f761fe3067cd Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 31 Aug 2023 09:36:24 +0200 Subject: [PATCH] wip: client-side logging --- api/typeDefs/user.js | 4 +- components/log.js | 55 +++++++++++++++++++ components/serviceworker.js | 21 ++++++- fragments/users.js | 8 ++- lib/validate.js | 3 +- pages/_app.js | 25 +++++---- pages/api/log/index.js | 17 ++++++ pages/settings.js | 33 ++++++++++- .../migration.sql | 11 ++++ prisma/schema.prisma | 7 +++ sw/index.js | 12 +++- 11 files changed, 176 insertions(+), 20 deletions(-) create mode 100644 components/log.js create mode 100644 pages/api/log/index.js create mode 100644 prisma/migrations/20230831054014_client_side_logging/migration.sql diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index ebb398eb64..6397feabf3 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -24,7 +24,8 @@ export default gql` noteEarning: Boolean!, noteAllDescendants: Boolean!, noteMentions: Boolean!, noteDeposits: Boolean!, noteInvites: Boolean!, noteJobIndicator: Boolean!, noteCowboyHat: Boolean!, hideInvoiceDesc: Boolean!, hideFromTopUsers: Boolean!, hideCowboyHat: Boolean!, clickToLoadImg: Boolean!, - wildWestMode: Boolean!, greeterMode: Boolean!, nostrPubkey: String, nostrRelays: [String!], hideBookmarks: Boolean!): User + wildWestMode: Boolean!, greeterMode: Boolean!, nostrPubkey: String, nostrRelays: [String!], hideBookmarks: Boolean!, + clientSideLogging: Boolean!): User setPhoto(photoId: ID!): Int! upsertBio(bio: String!): User! setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean @@ -81,6 +82,7 @@ export default gql` hideFromTopUsers: Boolean! hideCowboyHat: Boolean! hideBookmarks: Boolean! + clientSideLogging: Boolean! clickToLoadImg: Boolean! wildWestMode: Boolean! greeterMode: Boolean! diff --git a/components/log.js b/components/log.js new file mode 100644 index 0000000000..06d9c6544d --- /dev/null +++ b/components/log.js @@ -0,0 +1,55 @@ +import { createContext, useCallback, useContext, useEffect } from 'react' +import { useMe } from './me' + +const LoggerContext = createContext() + +export function LoggerProvider ({ children }) { + const me = useMe() + + const logLevel = useCallback(level => { + return async (event) => { + if (!me?.clientSideLogging) return + const env = { + userAgent: window.navigator.userAgent + } + const body = { + level, + env, + event: typeof event === 'string' ? { message: event } : event + } + await fetch('/api/log', { + method: 'post', + headers: { + 'Content-type': 'application/json' + }, + body: JSON.stringify(body) + }).catch(console.error) + } + }, [me?.clientSideLogging]) + + const logger = { + log: logLevel('log'), + warn: logLevel('warn'), + error: logLevel('error') + } + + useEffect(() => { + const channel = new BroadcastChannel('log') + channel.onmessage = event => { + const level = event.data?.level || 'log' + delete event.data?.level + logger[level](event.data) + } + return () => channel.close() + }, [logger]) + + return ( + + {children} + + ) +} + +export function useLogger () { + return useContext(LoggerContext) +} diff --git a/components/serviceworker.js b/components/serviceworker.js index f6cb036847..ddf84e9306 100644 --- a/components/serviceworker.js +++ b/components/serviceworker.js @@ -1,6 +1,7 @@ import { createContext, useContext, useEffect, useState, useCallback } from 'react' import { Workbox } from 'workbox-window' import { gql, useMutation } from '@apollo/client' +import { useLogger } from './log' const applicationServerKey = process.env.NEXT_PUBLIC_VAPID_PUBKEY @@ -34,6 +35,7 @@ export const ServiceWorkerProvider = ({ children }) => { } } `) + const logger = useLogger() // I am not entirely sure if this is needed since at least in Brave, // using `registration.pushManager.subscribe` also prompts the user. @@ -60,6 +62,8 @@ export const ServiceWorkerProvider = ({ children }) => { // Brave users must enable a flag in brave://settings/privacy first // see https://stackoverflow.com/a/69624651 let pushSubscription = await registration.pushManager.subscribe(subscribeOptions) + const { endpoint } = pushSubscription + logger.log({ message: 'subscribed to push notifications', endpoint }) // convert keys from ArrayBuffer to string pushSubscription = JSON.parse(JSON.stringify(pushSubscription)) // Send subscription to service worker to save it so we can use it later during `pushsubscriptionchange` @@ -68,24 +72,30 @@ export const ServiceWorkerProvider = ({ children }) => { action: 'STORE_SUBSCRIPTION', subscription: pushSubscription }) + logger.log({ message: 'sent STORE_SUBSCRIPTION to service worker', endpoint }) // send subscription to server const variables = { - endpoint: pushSubscription.endpoint, + endpoint, p256dh: pushSubscription.keys.p256dh, auth: pushSubscription.keys.auth } await savePushSubscription({ variables }) + logger.log('sent push subscription to server', { endpoint }) } const unsubscribeFromPushNotifications = async (subscription) => { await subscription.unsubscribe() const { endpoint } = subscription + logger.log({ message: 'unsubscribed from push notifications', endpoint }) await deletePushSubscription({ variables: { endpoint } }) + logger.log({ message: 'deleted push subscription from server', endpoint }) } const togglePushSubscription = useCallback(async () => { const pushSubscription = await registration.pushManager.getSubscription() - if (pushSubscription) return unsubscribeFromPushNotifications(pushSubscription) + if (pushSubscription) { + return unsubscribeFromPushNotifications(pushSubscription) + } return subscribeToPushNotifications() }) @@ -100,12 +110,17 @@ export const ServiceWorkerProvider = ({ children }) => { // we sync with server manually by checking on every page reload if the push subscription changed. // see https://medium.com/@madridserginho/how-to-handle-webpush-api-pushsubscriptionchange-event-in-modern-browsers-6e47840d756f navigator?.serviceWorker?.controller?.postMessage?.({ action: 'SYNC_SUBSCRIPTION' }) + logger.log('sent SYNC_SUBSCRIPTION to service worker') }, []) useEffect(() => { - if (!support.serviceWorker) return + if (!support.serviceWorker) { + logger.log('device does not support service worker') + return + } const wb = new Workbox('/sw.js', { scope: '/' }) wb.register().then(registration => { + logger.log('service worker registration successful') setRegistration(registration) }) }, [support.serviceWorker]) diff --git a/fragments/users.js b/fragments/users.js index 30709aa505..194131ce17 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -30,6 +30,7 @@ export const ME = gql` hideFromTopUsers hideCowboyHat clickToLoadImg + clientSideLogging wildWestMode greeterMode lastCheckedJobs @@ -54,6 +55,7 @@ export const SETTINGS_FIELDS = gql` hideCowboyHat hideBookmarks clickToLoadImg + clientSideLogging nostrPubkey nostrRelays wildWestMode @@ -82,13 +84,15 @@ mutation setSettings($tipDefault: Int!, $turboTipping: Boolean!, $fiatCurrency: $noteEarning: Boolean!, $noteAllDescendants: Boolean!, $noteMentions: Boolean!, $noteDeposits: Boolean!, $noteInvites: Boolean!, $noteJobIndicator: Boolean!, $noteCowboyHat: Boolean!, $hideInvoiceDesc: Boolean!, $hideFromTopUsers: Boolean!, $hideCowboyHat: Boolean!, $clickToLoadImg: Boolean!, - $wildWestMode: Boolean!, $greeterMode: Boolean!, $nostrPubkey: String, $nostrRelays: [String!], $hideBookmarks: Boolean!) { + $wildWestMode: Boolean!, $greeterMode: Boolean!, $nostrPubkey: String, $nostrRelays: [String!], $hideBookmarks: Boolean!, + $clientSideLogging: Boolean!) { setSettings(tipDefault: $tipDefault, turboTipping: $turboTipping, fiatCurrency: $fiatCurrency, noteItemSats: $noteItemSats, noteEarning: $noteEarning, noteAllDescendants: $noteAllDescendants, noteMentions: $noteMentions, noteDeposits: $noteDeposits, noteInvites: $noteInvites, noteJobIndicator: $noteJobIndicator, noteCowboyHat: $noteCowboyHat, hideInvoiceDesc: $hideInvoiceDesc, hideFromTopUsers: $hideFromTopUsers, hideCowboyHat: $hideCowboyHat, clickToLoadImg: $clickToLoadImg, - wildWestMode: $wildWestMode, greeterMode: $greeterMode, nostrPubkey: $nostrPubkey, nostrRelays: $nostrRelays, hideBookmarks: $hideBookmarks) { + wildWestMode: $wildWestMode, greeterMode: $greeterMode, nostrPubkey: $nostrPubkey, nostrRelays: $nostrRelays, hideBookmarks: $hideBookmarks, + clientSideLogging: $clientSideLogging) { ...SettingsFields } } diff --git a/lib/validate.js b/lib/validate.js index 59e554b8c1..44dfca8ffb 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -216,7 +216,8 @@ export const settingsSchema = object({ string().matches(WS_REGEXP, 'invalid web socket address') ).max(NOSTR_MAX_RELAY_NUM, ({ max, value }) => `${Math.abs(max - value.length)} too many`), - hideBookmarks: boolean() + hideBookmarks: boolean(), + clientSideLogging: boolean() }) const warningMessage = 'If I logout, even accidentally, I will never be able to access my account again' diff --git a/pages/_app.js b/pages/_app.js index 5b912ceee8..42fc720345 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -15,6 +15,7 @@ import { ServiceWorkerProvider } from '../components/serviceworker' import { SSR } from '../lib/constants' import NProgress from 'nprogress' import 'nprogress/nprogress.css' +import { LoggerProvider } from '../components/log' NProgress.configure({ showSpinner: false @@ -87,17 +88,19 @@ function MyApp ({ Component, pageProps: { ...props } }) { - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/pages/api/log/index.js b/pages/api/log/index.js new file mode 100644 index 0000000000..d457054572 --- /dev/null +++ b/pages/api/log/index.js @@ -0,0 +1,17 @@ +import { getServerSession } from 'next-auth' +import models from '../../../api/models' +import { getAuthOptions } from '../auth/[...nextauth]' + +export default async (req, res) => { + const session = await getServerSession(req, res, getAuthOptions(req)) + + if (!session.user) return res.status(401).json({ status: 'unauthorized' }) + + // TODO: + // Should we scramble user IDs by hashing? + // If no key is involved, anyone with knowledge of user IDs could hash IDs and compare hashes. + // If a key is involved during hashing, only we can compare hashes of user IDs. + await models.log.create({ data: { data: { userId: session.user.id, ...req.body } } }) + + return res.status(200).json({ status: 'ok' }) +} diff --git a/pages/settings.js b/pages/settings.js index 58d929fcec..429f0389b1 100644 --- a/pages/settings.js +++ b/pages/settings.js @@ -75,7 +75,8 @@ export default function Settings ({ ssrData }) { greeterMode: settings?.greeterMode, nostrPubkey: settings?.nostrPubkey ? bech32encode(settings.nostrPubkey) : '', nostrRelays: settings?.nostrRelays?.length ? settings?.nostrRelays : [''], - hideBookmarks: settings?.hideBookmarks + hideBookmarks: settings?.hideBookmarks, + clientSideLogging: settings?.clientSideLogging }} schema={settingsSchema} onSubmit={async ({ tipDefault, nostrPubkey, nostrRelays, ...values }) => { @@ -232,6 +233,36 @@ export default function Settings ({ ssrData }) { hide my bookmarks from other stackers} name='hideBookmarks' + groupClassName='mb-0' + /> + enable client-side logging + +
    + { + /** TODO: better disclaimer */ + } +
  • capture and send log events back to SN
  • +
  • this information is used to identify and fix bugs
  • +
  • this information includes: +
    • timestamps
    +
    • your user id
    +
    • your user agent
    +
  • + { + /** + * TODO: Should we anonymize data? + *
  • insert something about anonymization here
  • + * TODO: Should we make information we collect easily accessible? + *
  • all your information is available here
  • + */ + } +
+
+ + } + name='clientSideLogging' />
content
1) { - console.error(`more than one notification with tag ${tag} found`) + const message = `[sw:push] more than one notification with tag ${tag} found` + logChannel.log({ level: 'error', message }) + console.error(message) return null } if (notifications.length === 0) { @@ -85,7 +88,9 @@ self.addEventListener('notificationclick', (event) => { // https://medium.com/@madridserginho/how-to-handle-webpush-api-pushsubscriptionchange-event-in-modern-browsers-6e47840d756f self.addEventListener('message', (event) => { + logChannel.postMessage({ message: '[sw:message] received message', action: event.data.action }) if (event.data.action === 'STORE_SUBSCRIPTION') { + logChannel.postMessage({ message: '[sw:message] storing subscription in IndexedDB', endpoint: event.data.subscription.endpoint }) return event.waitUntil(storage.setItem('subscription', event.data.subscription)) } if (event.data.action === 'SYNC_SUBSCRIPTION') { @@ -96,14 +101,17 @@ self.addEventListener('message', (event) => { async function handlePushSubscriptionChange (oldSubscription, newSubscription) { // https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/pushsubscriptionchange_event // fallbacks since browser may not set oldSubscription and newSubscription + logChannel.postMessage('[sw:handlePushSubscriptionChange] invoked') oldSubscription ??= await storage.getItem('subscription') newSubscription ??= await self.registration.pushManager.getSubscription() if (!newSubscription) { // no subscription exists at the moment + logChannel.postMessage('[sw:handlePushSubscriptionChange] no existing subscription found') return } if (oldSubscription?.endpoint === newSubscription.endpoint) { // subscription did not change. no need to sync with server + logChannel.postMessage('[sw:handlePushSubscriptionChange] old subscription matches existing subscription') return } // convert keys from ArrayBuffer to string @@ -128,9 +136,11 @@ async function handlePushSubscriptionChange (oldSubscription, newSubscription) { }, body }) + logChannel.postMessage({ message: '[sw:handlePushSubscriptionChange] synced push subscription with server', endpoint: variables.endpoint, oldEndpoint: variables.oldEndpoint }) await storage.setItem('subscription', JSON.parse(JSON.stringify(newSubscription))) } self.addEventListener('pushsubscriptionchange', (event) => { + logChannel.postMessage('[sw:pushsubscriptionchange] received event') event.waitUntil(handlePushSubscriptionChange(event.oldSubscription, event.newSubscription)) }, false)