Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature flagged mouse movement collection #922

Merged
merged 20 commits into from
Dec 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading