Skip to content

Commit

Permalink
Feature flagged mouse movement collection (#922)
Browse files Browse the repository at this point in the history
* Collector

* comments

* Add type for react

* Trigger only on form interaction

* latest

* Cleaned up collector, fixing ref issue

* Collecting data for mouse, touch and keyboard events

* Lint fix

* Correcting types and adding mongo atlas event adding

* Adding provider middleman for mongo

* Provider with mongo setup

* bundle side api not used

* Moving db config into db package

* Exporting db

* fixing db import

* cypress tests fix

* bundling error fix

---------

Co-authored-by: Chris Taylor <forgetso86@gmail.com>
  • Loading branch information
HughParry and forgetso authored Dec 20, 2023
1 parent 91dfad5 commit b57ccd6
Show file tree
Hide file tree
Showing 20 changed files with 281 additions and 3 deletions.
2 changes: 1 addition & 1 deletion demos/client-example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import {
} from '@prosopo/types'
import { ExtensionAccountSelect, Procaptcha } from '@prosopo/procaptcha-react'
import { useState } from 'react'

const corsHeaders = {
'Access-Control-Allow-Origin': '*', // Required for CORS support to work
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE',
Expand Down Expand Up @@ -51,6 +50,7 @@ function App() {
defaultEnvironment:
(process.env.PROSOPO_DEFAULT_ENVIRONMENT as EnvironmentTypes) || EnvironmentTypesSchema.enum.development,
serverUrl: process.env.PROSOPO_SERVER_URL || '',
atlasUri: process.env._DEV_ONLY_WATCH_EVENTS === 'true' || false,
})

const label = isLogin ? 'Login' : 'Sign up'
Expand Down
1 change: 1 addition & 0 deletions dev/config/src/vite/vite.frontend.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export default async function (
'process.env.PROSOPO_DEFAULT_ENVIRONMENT': JSON.stringify(process.env.PROSOPO_DEFAULT_ENVIRONMENT),
'process.env.PROSOPO_DEFAULT_NETWORK': JSON.stringify(process.env.PROSOPO_DEFAULT_NETWORK),
'process.env.PROSOPO_SERVER_URL': JSON.stringify(process.env.PROSOPO_SERVER_URL),
'process.env._DEV_ONLY_WATCH_EVENTS': JSON.stringify(process.env._DEV_ONLY_WATCH_EVENTS),
'process.env.PROSOPO_CONTRACT_ADDRESS': JSON.stringify(process.env.PROSOPO_CONTRACT_ADDRESS),
// only needed if bundling with a site key
'process.env.PROSOPO_SITE_KEY': JSON.stringify(process.env.PROSOPO_SITE_KEY),
Expand Down
6 changes: 5 additions & 1 deletion packages/api/src/api/ProviderApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { AccountId } from '@prosopo/types'
import { AccountId, StoredEvents } from '@prosopo/types'
import {
ApiPaths,
CaptchaSolution,
Expand Down Expand Up @@ -91,6 +91,10 @@ export default class ProviderApi extends HttpClientBase {
return this.post(ApiPaths.VerifyCaptchaSolution, payload as VerifySolutionBodyType)
}

public submitUserEvents(events: StoredEvents, accountId: string) {
return this.post(ApiPaths.SubmitUserEvents, { events, accountId })
}

public getProviderStatus(): Promise<ProviderRegistered> {
return this.fetch(ApiPaths.GetProviderStatus)
}
Expand Down
61 changes: 61 additions & 0 deletions packages/database/src/eventsDatabase/eventsDatabase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { StoredEvents } from '@prosopo/types'
import mongoose from 'mongoose'

const captchaEventSchema = new mongoose.Schema({
touchEvents: [
{
x: Number,
y: Number,
timestamp: Number,
},
],
mouseEvents: [
{
x: Number,
y: Number,
timestamp: Number,
},
],
keyboardEvents: [
{
key: String,
timestamp: Number,
isShiftKey: Boolean,
isCtrlKey: Boolean,
},
],
accountId: String,
})

const CaptchaEvent = mongoose.model('CaptchaEvent', captchaEventSchema)

const addCaptchaEventRecord = async (record: {
touchEvents?: { x: number; y: number; timestamp: number }[]
mouseEvents?: { x: number; y: number; timestamp: number }[]
keyboardEvents?: { key: string; timestamp: number; isShiftKey?: boolean; isCtrlKey?: boolean }[]
accountId: string
}): Promise<void> => {
try {
const newRecord = new CaptchaEvent(record)
await newRecord.save()
console.log('Record added successfully')
} catch (error) {
console.error('Error adding record to the database:', error)
}
}

export const saveCaptchaEvent = async (events: StoredEvents, accountId: string, atlasUri: string) => {
await mongoose
.connect(atlasUri)
.then(() => console.log('Connected to MongoDB Atlas'))
.catch((err) => console.error('Error connecting to MongoDB:', err))

const captchaEventData = {
...events,
accountId,
}

addCaptchaEventRecord(captchaEventData)
.then(() => console.log('Captcha event data saved'))
.catch((error) => console.error('Error saving captcha event data:', error))
}
14 changes: 14 additions & 0 deletions packages/database/src/eventsDatabase/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright 2021-2023 Prosopo (UK) Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
export * from './eventsDatabase.js'
1 change: 1 addition & 0 deletions packages/database/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@
// See the License for the specific language governing permissions and
// limitations under the License.
export * from './databases/index.js'
export * from './eventsDatabase/index.js'
1 change: 1 addition & 0 deletions packages/procaptcha-bundle/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ const getConfig = (siteKey?: string): ProcaptchaConfigOptional => {
address: siteKey,
},
serverUrl: process.env.PROSOPO_SERVER_URL || '',
mongoAtlasUri: process.env._DEV_ONLY_WATCH_EVENTS === 'true' || false,
})
}

Expand Down
9 changes: 8 additions & 1 deletion packages/procaptcha-react/src/components/Procaptcha.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { darkTheme, lightTheme } from './theme.js'
import { useMemo, useRef, useState } from 'react'
import CaptchaComponent from './CaptchaComponent.js'
import Checkbox from './Checkbox.js'
import Collector from './collector.js'
import Modal from './Modal.js'

const logoStyle = css`
Expand Down Expand Up @@ -103,7 +104,7 @@ const useProcaptcha = (): [ProcaptchaState, ProcaptchaStateUpdateFn] => {
const [successfullChallengeTimeout, setSuccessfullChallengeTimeout] = useRefAsState<NodeJS.Timeout | undefined>(
undefined
)

const [sendData, setSendData] = useState(false)
return [
// the state
{
Expand All @@ -120,6 +121,7 @@ const useProcaptcha = (): [ProcaptchaState, ProcaptchaStateUpdateFn] => {
timeout,
blockNumber,
successfullChallengeTimeout,
sendData,
},
// and method to update the state
(nextState: Partial<ProcaptchaState>) => {
Expand All @@ -139,6 +141,7 @@ const useProcaptcha = (): [ProcaptchaState, ProcaptchaStateUpdateFn] => {
if (nextState.timeout !== undefined) setTimeout(nextState.timeout)
if (nextState.successfullChallengeTimeout !== undefined) setSuccessfullChallengeTimeout(nextState.timeout)
if (nextState.blockNumber !== undefined) setBlockNumber(nextState.blockNumber)
if (nextState.sendData !== undefined) setSendData(nextState.sendData)
},
]
}
Expand All @@ -150,6 +153,7 @@ export const Procaptcha = (props: ProcaptchaProps) => {

const [state, updateState] = useProcaptcha()
console.log('state', state)

const manager = Manager(config, state, updateState, callbacks)
const styleWidth = { maxWidth: '400px', minWidth: '200px', margin: '8px' }
const themeColor = props.config.theme === 'light' ? 'light' : 'dark'
Expand Down Expand Up @@ -271,6 +275,9 @@ export const Procaptcha = (props: ProcaptchaProps) => {
</div>
</div>
</div>
{config.devOnlyWatchEvents && (
<Collector onProcessData={manager.exportData} sendData={state.showModal}></Collector>
)}
</div>
)
}
Expand Down
36 changes: 36 additions & 0 deletions packages/procaptcha-react/src/components/collector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { MutableRefObject, useEffect, useRef, useState } from 'react'
import { ProsopoKeyboardEvent, ProsopoMouseEvent, ProsopoTouchEvent, StoredEvents } from '@prosopo/types'
import { startCollector } from '@prosopo/procaptcha'

type CollectorProps = {
onProcessData: (data: StoredEvents) => void
sendData: boolean
}

const Collector = ({ onProcessData, sendData }: CollectorProps) => {
const [mouseEvents, setStoredMouseEvents] = useState<ProsopoMouseEvent[]>([])
const [touchEvents, setStoredTouchEvents] = useState<ProsopoTouchEvent[]>([])
const [keyboardEvents, setStoredKeyboardEvents] = useState<ProsopoKeyboardEvent[]>([])

const ref: MutableRefObject<HTMLDivElement | null> = useRef<HTMLDivElement>(null)

useEffect(() => {
if (ref && ref.current) {
startCollector(setStoredMouseEvents, setStoredTouchEvents, setStoredKeyboardEvents, ref.current)
}
}, [])

useEffect(() => {
const userEvents = {
mouseEvents,
touchEvents,
keyboardEvents,
}

onProcessData(userEvents)
}, [sendData])

return <div ref={ref}></div>
}

export default Collector
1 change: 1 addition & 0 deletions packages/procaptcha-react/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"jsxImportSource": "@emotion/react"
},
"include": ["src", "src/**/*.json"],

"references": [
{
"path": "../api"
Expand Down
14 changes: 14 additions & 0 deletions packages/procaptcha/src/modules/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
ProcaptchaClientConfigInput,
ProcaptchaClientConfigOutput,
ProcaptchaConfigSchema,
StoredEvents,
} from '@prosopo/types'
import { GetCaptchaResponse, ProviderApi } from '@prosopo/api'
import { Keyring } from '@polkadot/keyring'
Expand Down Expand Up @@ -104,12 +105,14 @@ export function Manager(
onError: alertError,
onHuman: (output: { user: string; dapp: string; commitmentId?: string; providerUrl?: string }) => {
console.log('onHuman event triggered', output)
updateState({ sendData: !state.sendData })
},
onExtensionNotFound: () => {
alert('No extension found')
},
onFailed: () => {
alert('Captcha challenge failed. Please try again')
updateState({ sendData: !state.sendData })
},
onExpired: () => {
alert('Completed challenge has expired, please try again')
Expand All @@ -119,6 +122,7 @@ export function Manager(
},
onOpen: () => {
console.log('onOpen event triggered')
updateState({ sendData: !state.sendData })
},
onClose: () => {
console.log('onClose event triggered')
Expand Down Expand Up @@ -568,11 +572,21 @@ export function Manager(
)
}

const exportData = async (events: StoredEvents) => {
const providerUrl = storage.getProviderUrl() || state.captchaApi?.provider.provider.url.toString()
if (!providerUrl) {
return
}
const providerApi = await loadProviderApi(providerUrl)
await providerApi.submitUserEvents(events, getAccount().account.address)
}

return {
start,
cancel,
submit,
select,
nextRound,
exportData,
}
}
82 changes: 82 additions & 0 deletions packages/procaptcha/src/modules/collector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { ProsopoKeyboardEvent, ProsopoMouseEvent, ProsopoTouchEvent } from '@prosopo/types'

const COLLECTOR_LIMIT = 1000

type SetStateAction<T> = T | ((prevState: T) => T)
type SetStateEvent<T> = (setValueFunc: SetStateAction<T[]>) => void
type SetMouseEvent = (setValueFunc: SetStateAction<ProsopoMouseEvent[]>) => void
type SetKeyboardEvent = (setValueFunc: SetStateAction<ProsopoKeyboardEvent[]>) => void
type SetTouchEvent = (setValueFunc: SetStateAction<ProsopoTouchEvent[]>) => void

const storeLog = <T>(event: T, setEvents: SetStateEvent<T>) => {
setEvents((currentEvents) => {
let newEvents = [...currentEvents, event]
if (newEvents.length > COLLECTOR_LIMIT) {
newEvents = newEvents.slice(1)
}
return newEvents
})
}

const logMouseEvent = (event: globalThis.MouseEvent, setMouseEvent: SetMouseEvent) => {
const storedEvent: ProsopoMouseEvent = {
x: event.x,
y: event.y,
timestamp: event.timeStamp,
}
storeLog(storedEvent, setMouseEvent)
}

const logKeyboardEvent = (event: globalThis.KeyboardEvent, setKeyboardEvent: SetKeyboardEvent) => {
const storedEvent: ProsopoKeyboardEvent = {
key: event.key,
timestamp: event.timeStamp,
isShiftKey: event.shiftKey,
isCtrlKey: event.ctrlKey,
}
storeLog(storedEvent, setKeyboardEvent)
}

const logTouchEvent = (event: globalThis.TouchEvent, setTouchEvent: SetTouchEvent) => {
// Iterate over the TouchList (map doesn't work on TouchList)
for (let i = 0; i < event.touches.length; i++) {
const touch = event.touches[i]
if (!touch) {
continue
}
storeLog({ x: touch.clientX, y: touch.clientY, timestamp: event.timeStamp }, setTouchEvent)
}
}

export const startCollector = (
setStoredMouseEvents: SetMouseEvent,
setStoredTouchEvents: SetTouchEvent,
setStoredKeyboardEvents: SetKeyboardEvent,
rootElement: HTMLDivElement
) => {
const form = findContainingForm(rootElement)
if (form) {
// Add listeners to mouse
form.addEventListener('mousemove', (e) => logMouseEvent(e, setStoredMouseEvents))

// Add listeners to keyboard
form.addEventListener('keydown', (e) => logKeyboardEvent(e, setStoredKeyboardEvents))
form.addEventListener('keyup', (e) => logKeyboardEvent(e, setStoredKeyboardEvents))

// Add listeners to touch
form.addEventListener('touchstart', (e) => logTouchEvent(e, setStoredTouchEvents))
form.addEventListener('touchend', (e) => logTouchEvent(e, setStoredTouchEvents))
form.addEventListener('touchcancel', (e) => logTouchEvent(e, setStoredTouchEvents))
form.addEventListener('touchmove', (e) => logTouchEvent(e, setStoredTouchEvents))
}
}

const findContainingForm = (element: Element): HTMLFormElement | null => {
if (element.tagName === 'FORM') {
return element as HTMLFormElement
}
if (element.parentElement) {
return findContainingForm(element.parentElement)
}
return null
}
1 change: 1 addition & 0 deletions packages/procaptcha/src/modules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@
// limitations under the License.
export * from './Manager.js'
export * from './ProsopoCaptchaApi.js'
export * from './collector.js'
1 change: 1 addition & 0 deletions packages/procaptcha/src/types/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export interface ProcaptchaState {
timeout: NodeJS.Timeout | undefined // the timer for the captcha challenge. undefined if not set
successfullChallengeTimeout: NodeJS.Timeout | undefined // the timer for the captcha challenge. undefined if not set
blockNumber: number | undefined // the block number in which the random provider was chosen. undefined if not set
sendData: boolean // whether to trigger sending user event data (mouse, keyboard, touch) to the provider
}

/**
Expand Down
Loading

0 comments on commit b57ccd6

Please sign in to comment.