@vobs/captcha 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +56 -0
- package/package.json +28 -0
- package/src/index.test.ts +417 -0
- package/src/index.ts +279 -0
- package/src/slider.ts +566 -0
- package/src/styles/styles.css +437 -0
package/src/slider.ts
ADDED
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
import {
|
|
2
|
+
addEventListener,
|
|
3
|
+
createElement,
|
|
4
|
+
createText,
|
|
5
|
+
effect,
|
|
6
|
+
insertBefore,
|
|
7
|
+
setAttribute,
|
|
8
|
+
setProperty,
|
|
9
|
+
state,
|
|
10
|
+
type Signal,
|
|
11
|
+
type VobsNode
|
|
12
|
+
} from '@vobs/vobs'
|
|
13
|
+
import { onDispose } from '@vobs/reactivity'
|
|
14
|
+
import { Captcha, type CaptchaStatus, type CaptchaSubmitContext } from './index'
|
|
15
|
+
|
|
16
|
+
export type SliderShape = 'puzzle' | 'circle' | 'square' | 'triangle'
|
|
17
|
+
|
|
18
|
+
export interface SliderCaptchaChallenge {
|
|
19
|
+
readonly id: string
|
|
20
|
+
readonly payload: SliderCaptchaPayload
|
|
21
|
+
readonly expiresAt: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SliderCaptchaPayload {
|
|
25
|
+
readonly image?: string
|
|
26
|
+
readonly width: number
|
|
27
|
+
readonly height: number
|
|
28
|
+
readonly startX?: number
|
|
29
|
+
readonly targetX: number
|
|
30
|
+
readonly targetY: number
|
|
31
|
+
readonly rotation?: number
|
|
32
|
+
readonly decoys?: readonly SliderCaptchaDecoy[]
|
|
33
|
+
readonly decoyX?: number
|
|
34
|
+
readonly decoyY?: number
|
|
35
|
+
readonly decoyRotation?: number
|
|
36
|
+
readonly pieceWidth: number
|
|
37
|
+
readonly pieceHeight: number
|
|
38
|
+
readonly tolerance?: number
|
|
39
|
+
readonly shape?: SliderShape
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SliderCaptchaDecoy {
|
|
43
|
+
readonly x: number
|
|
44
|
+
readonly y: number
|
|
45
|
+
readonly rotation?: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SliderTrailPoint {
|
|
49
|
+
readonly x: number
|
|
50
|
+
readonly y: number
|
|
51
|
+
readonly t: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface SliderTrailAnalysis {
|
|
55
|
+
readonly pointCount: number
|
|
56
|
+
readonly duration: number
|
|
57
|
+
readonly distance: number
|
|
58
|
+
readonly averageSpeed: number
|
|
59
|
+
readonly maxSpeed: number
|
|
60
|
+
readonly directionChanges: number
|
|
61
|
+
readonly verticalTravel: number
|
|
62
|
+
readonly averageInterval: number
|
|
63
|
+
readonly looksHuman: boolean
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface CaptchaDeviceSignals {
|
|
67
|
+
readonly sessionId: string
|
|
68
|
+
readonly userAgent?: string
|
|
69
|
+
readonly platform?: string
|
|
70
|
+
readonly language?: string
|
|
71
|
+
readonly languages?: readonly string[]
|
|
72
|
+
readonly timezone?: string
|
|
73
|
+
readonly screen?: { readonly width: number; readonly height: number; readonly pixelRatio: number }
|
|
74
|
+
readonly viewport?: { readonly width: number; readonly height: number }
|
|
75
|
+
readonly touchPoints?: number
|
|
76
|
+
readonly hardwareConcurrency?: number
|
|
77
|
+
readonly deviceMemory?: number
|
|
78
|
+
readonly webdriver?: boolean
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface SliderCaptchaResult {
|
|
82
|
+
readonly x: number
|
|
83
|
+
readonly y: number
|
|
84
|
+
readonly trail: readonly SliderTrailPoint[]
|
|
85
|
+
readonly duration: number
|
|
86
|
+
readonly analysis: SliderTrailAnalysis
|
|
87
|
+
readonly deviceSignals?: CaptchaDeviceSignals
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface SliderCaptchaProps {
|
|
91
|
+
readonly challenge?: SliderCaptchaValue<SliderCaptchaChallenge | null>
|
|
92
|
+
readonly status?: SliderCaptchaValue<CaptchaStatus>
|
|
93
|
+
readonly disabled?: SliderCaptchaValue<boolean>
|
|
94
|
+
readonly collectDeviceSignals?: SliderCaptchaValue<boolean>
|
|
95
|
+
readonly onSubmit?: (result: SliderCaptchaResult, challenge: SliderCaptchaChallenge) => void | PromiseLike<unknown>
|
|
96
|
+
readonly onRetry?: () => void
|
|
97
|
+
readonly onCancel?: () => void
|
|
98
|
+
readonly retryLabel?: SliderCaptchaValue<string>
|
|
99
|
+
readonly refreshingLabel?: SliderCaptchaValue<string>
|
|
100
|
+
readonly retryIcon?: VobsNode | (() => VobsNode | null | undefined)
|
|
101
|
+
readonly cancelLabel?: SliderCaptchaValue<string>
|
|
102
|
+
readonly loadingLabel?: SliderCaptchaValue<string>
|
|
103
|
+
readonly emptyLabel?: SliderCaptchaValue<string>
|
|
104
|
+
readonly expiredLabel?: SliderCaptchaValue<string>
|
|
105
|
+
readonly error?: SliderCaptchaValue<unknown>
|
|
106
|
+
readonly errorLabel?: SliderCaptchaValue<string>
|
|
107
|
+
readonly label?: SliderCaptchaValue<string>
|
|
108
|
+
readonly dragLabel?: SliderCaptchaValue<string>
|
|
109
|
+
readonly successDuration?: SliderCaptchaValue<number>
|
|
110
|
+
readonly onSuccessDismiss?: () => void
|
|
111
|
+
readonly class?: SliderCaptchaValue<string>
|
|
112
|
+
readonly className?: SliderCaptchaValue<string>
|
|
113
|
+
readonly id?: SliderCaptchaValue<string>
|
|
114
|
+
readonly title?: SliderCaptchaValue<string>
|
|
115
|
+
readonly role?: SliderCaptchaValue<string>
|
|
116
|
+
readonly [name: `aria-${string}`]: string | number | boolean | undefined
|
|
117
|
+
readonly [name: `data-${string}`]: string | number | boolean | undefined
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type SliderCaptchaValue<T> = T | Signal<T> | (() => T)
|
|
121
|
+
|
|
122
|
+
const sessionId = createSessionId()
|
|
123
|
+
|
|
124
|
+
export function collectCaptchaDeviceSignals(): CaptchaDeviceSignals {
|
|
125
|
+
if (typeof navigator === 'undefined' || typeof window === 'undefined') {
|
|
126
|
+
return { sessionId }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let timezone: string | undefined
|
|
130
|
+
try {
|
|
131
|
+
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
132
|
+
} catch {
|
|
133
|
+
timezone = undefined
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const screen = window.screen
|
|
137
|
+
const navigatorWithMemory = navigator as Navigator & { deviceMemory?: number }
|
|
138
|
+
return {
|
|
139
|
+
sessionId,
|
|
140
|
+
userAgent: navigator.userAgent,
|
|
141
|
+
platform: navigator.platform,
|
|
142
|
+
language: navigator.language,
|
|
143
|
+
languages: navigator.languages ? [...navigator.languages] : undefined,
|
|
144
|
+
timezone,
|
|
145
|
+
screen: {
|
|
146
|
+
width: screen.width,
|
|
147
|
+
height: screen.height,
|
|
148
|
+
pixelRatio: window.devicePixelRatio || 1
|
|
149
|
+
},
|
|
150
|
+
viewport: {
|
|
151
|
+
width: window.innerWidth,
|
|
152
|
+
height: window.innerHeight
|
|
153
|
+
},
|
|
154
|
+
touchPoints: navigator.maxTouchPoints,
|
|
155
|
+
hardwareConcurrency: navigator.hardwareConcurrency,
|
|
156
|
+
deviceMemory: navigatorWithMemory.deviceMemory,
|
|
157
|
+
webdriver: navigator.webdriver
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function SliderCaptcha(props: SliderCaptchaProps = {}): VobsNode {
|
|
162
|
+
const positions = new Map<string, number>()
|
|
163
|
+
const successDismissed = state(false)
|
|
164
|
+
let successTimer: ReturnType<typeof setTimeout> | undefined
|
|
165
|
+
let waitingForDismiss = false
|
|
166
|
+
|
|
167
|
+
effect(() => {
|
|
168
|
+
const currentStatus = readValue<CaptchaStatus>(props, 'status', 'idle')
|
|
169
|
+
if (currentStatus !== 'verified') {
|
|
170
|
+
waitingForDismiss = false
|
|
171
|
+
successDismissed.value = false
|
|
172
|
+
if (successTimer !== undefined) {
|
|
173
|
+
clearTimeout(successTimer)
|
|
174
|
+
successTimer = undefined
|
|
175
|
+
}
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
if (waitingForDismiss) return
|
|
179
|
+
waitingForDismiss = true
|
|
180
|
+
const duration = Math.max(0, readValue(props, 'successDuration', 1_000))
|
|
181
|
+
successTimer = setTimeout(() => {
|
|
182
|
+
successTimer = undefined
|
|
183
|
+
successDismissed.value = true
|
|
184
|
+
props.onSuccessDismiss?.()
|
|
185
|
+
}, duration)
|
|
186
|
+
})
|
|
187
|
+
onDispose(() => {
|
|
188
|
+
if (successTimer !== undefined) clearTimeout(successTimer)
|
|
189
|
+
positions.clear()
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
return Captcha<SliderCaptchaPayload>({
|
|
193
|
+
get challenge() { return readValue<SliderCaptchaChallenge | null>(props, 'challenge', null) },
|
|
194
|
+
get status() { return readValue<CaptchaStatus>(props, 'status', 'idle') },
|
|
195
|
+
get error() { return readValue<unknown>(props, 'error', undefined) },
|
|
196
|
+
get disabled() { return readValue(props, 'disabled', false) },
|
|
197
|
+
get retryLabel() {
|
|
198
|
+
return readValue<CaptchaStatus>(props, 'status', 'idle') === 'loading'
|
|
199
|
+
? readValue(props, 'refreshingLabel', 'Refreshing…')
|
|
200
|
+
: readValue(props, 'retryLabel', 'Retry')
|
|
201
|
+
},
|
|
202
|
+
get retryIcon() { return props.retryIcon },
|
|
203
|
+
get cancelLabel() { return readValue(props, 'cancelLabel', 'Cancel') },
|
|
204
|
+
get loadingLabel() { return readValue(props, 'loadingLabel', 'Loading captcha…') },
|
|
205
|
+
get emptyLabel() { return readValue(props, 'emptyLabel', 'Captcha is not ready.') },
|
|
206
|
+
get expiredLabel() { return readValue(props, 'expiredLabel', 'This captcha has expired.') },
|
|
207
|
+
get errorLabel() { return readValue(props, 'errorLabel', 'Captcha verification failed.') },
|
|
208
|
+
get label() { return readValue(props, 'label', '') },
|
|
209
|
+
get messagePlacement() { return 'none' as const },
|
|
210
|
+
get keepChallengeOnError() { return true },
|
|
211
|
+
get keepChallengeOnLoading() { return true },
|
|
212
|
+
get keepChallengeOnVerifying() { return true },
|
|
213
|
+
get showRetry() { return true },
|
|
214
|
+
get showRetryWhileLoading() { return true },
|
|
215
|
+
get showCancel() { return true },
|
|
216
|
+
get class() {
|
|
217
|
+
return classNames(
|
|
218
|
+
'vobs-slider-captcha',
|
|
219
|
+
successDismissed.value ? 'vobs-slider-captcha--dismissed' : undefined,
|
|
220
|
+
readString(props, 'class'),
|
|
221
|
+
readString(props, 'className')
|
|
222
|
+
)
|
|
223
|
+
},
|
|
224
|
+
get id() { return readString(props, 'id') },
|
|
225
|
+
get title() { return readString(props, 'title') },
|
|
226
|
+
get role() { return readString(props, 'role') },
|
|
227
|
+
onRetry: props.onRetry,
|
|
228
|
+
onCancel: props.onCancel,
|
|
229
|
+
onSubmit(answer, challenge) {
|
|
230
|
+
if (!isSliderResult(answer)) return
|
|
231
|
+
return props.onSubmit?.(answer, challenge as unknown as SliderCaptchaChallenge)
|
|
232
|
+
},
|
|
233
|
+
renderChallenge(context) {
|
|
234
|
+
return createSliderChallenge(
|
|
235
|
+
context,
|
|
236
|
+
props,
|
|
237
|
+
positions.get(context.challenge.id) ?? 0,
|
|
238
|
+
position => positions.set(context.challenge.id, position)
|
|
239
|
+
)
|
|
240
|
+
},
|
|
241
|
+
...readDataAndAriaProps(props)
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function createSliderChallenge(
|
|
246
|
+
context: CaptchaSubmitContext<SliderCaptchaPayload>,
|
|
247
|
+
props: SliderCaptchaProps,
|
|
248
|
+
initialPosition: number,
|
|
249
|
+
onPositionChange: (position: number) => void
|
|
250
|
+
): VobsNode {
|
|
251
|
+
const challenge = context.challenge
|
|
252
|
+
const payload = challenge.payload!
|
|
253
|
+
const interactionDisabled = context.disabled || context.status === 'error'
|
|
254
|
+
|| context.status === 'expired' || context.status === 'verified'
|
|
255
|
+
const wrapper = createElement('div')
|
|
256
|
+
const visual = createElement('div')
|
|
257
|
+
const target = createElement('div')
|
|
258
|
+
const targetImage = createElement('div')
|
|
259
|
+
const piece = createElement('div')
|
|
260
|
+
const pieceImage = createElement('div')
|
|
261
|
+
const track = createElement('div')
|
|
262
|
+
const trackPrompt = createElement('span')
|
|
263
|
+
const handle = createElement('button')
|
|
264
|
+
const handleText = createText(context.status === 'verified' ? '√' : readValue(props, 'dragLabel', '>'))
|
|
265
|
+
const trail: SliderTrailPoint[] = []
|
|
266
|
+
const startX = clamp(payload.startX ?? 0, 0, Math.max(0, payload.width - payload.pieceWidth))
|
|
267
|
+
const maxPosition = Math.max(0, payload.width - payload.pieceWidth - startX)
|
|
268
|
+
const position = state(clamp(initialPosition, 0, maxPosition))
|
|
269
|
+
const dragging = state(false)
|
|
270
|
+
const handleWidth = 50
|
|
271
|
+
let startClientX = 0
|
|
272
|
+
let startPosition = 0
|
|
273
|
+
let lastRecordedAt = 0
|
|
274
|
+
let startedAt = 0
|
|
275
|
+
|
|
276
|
+
setAttribute(wrapper, 'class', 'vobs-slider-captcha__challenge')
|
|
277
|
+
setAttribute(visual, 'class', 'vobs-slider-captcha__visual')
|
|
278
|
+
setAttribute(target, 'class', `vobs-slider-captcha__target vobs-slider-captcha__target--${payload.shape ?? 'puzzle'}`)
|
|
279
|
+
setAttribute(targetImage, 'class', 'vobs-slider-captcha__image')
|
|
280
|
+
setAttribute(piece, 'class', `vobs-slider-captcha__piece vobs-slider-captcha__piece--${payload.shape ?? 'puzzle'}`)
|
|
281
|
+
setAttribute(pieceImage, 'class', 'vobs-slider-captcha__image')
|
|
282
|
+
setAttribute(track, 'class', 'vobs-slider-captcha__track')
|
|
283
|
+
setAttribute(trackPrompt, 'class', 'vobs-slider-captcha__track-prompt')
|
|
284
|
+
setAttribute(handle, 'class', `vobs-slider-captcha__handle${context.status === 'verified' ? ' vobs-slider-captcha__handle--verified' : ''}`)
|
|
285
|
+
setAttribute(handle, 'type', 'button')
|
|
286
|
+
setAttribute(handle, 'role', 'slider')
|
|
287
|
+
setAttribute(handle, 'aria-label', context.status === 'verified' ? '验证通过' : readValue(props, 'dragLabel', '>'))
|
|
288
|
+
insertBefore(trackPrompt, createText('向右拖动滑块完成拼图'), null)
|
|
289
|
+
insertBefore(handle, handleText, null)
|
|
290
|
+
|
|
291
|
+
const visualImageStyle = payload.image
|
|
292
|
+
? `background-image: url(${quoteCssUrl(payload.image)}); background-size: ${payload.width}px ${payload.height}px;`
|
|
293
|
+
: ''
|
|
294
|
+
const imageAt = (x: number, y: number) => payload.image
|
|
295
|
+
? `${visualImageStyle} background-position: -${x}px -${y}px;`
|
|
296
|
+
: ''
|
|
297
|
+
setAttribute(visual, 'style', `width: ${payload.width}px; height: ${payload.height}px; ${visualImageStyle}`)
|
|
298
|
+
const targetRotation = rotationStyle(payload.rotation)
|
|
299
|
+
setAttribute(target, 'style', `left: ${payload.targetX}px; top: ${payload.targetY}px; width: ${payload.pieceWidth}px; height: ${payload.pieceHeight}px; ${targetRotation}`)
|
|
300
|
+
setAttribute(targetImage, 'style', `${counterRotationStyle(payload.rotation)} ${imageAt(payload.targetX, payload.targetY)}`)
|
|
301
|
+
setAttribute(pieceImage, 'style', `${counterRotationStyle(payload.rotation)} ${imageAt(payload.targetX, payload.targetY)}`)
|
|
302
|
+
setAttribute(piece, 'style', `top: ${payload.targetY}px; width: ${payload.pieceWidth}px; height: ${payload.pieceHeight}px; ${targetRotation}`)
|
|
303
|
+
setAttribute(track, 'style', `width: ${payload.width}px`)
|
|
304
|
+
insertBefore(target, targetImage, null)
|
|
305
|
+
const decoys = readDecoys(payload)
|
|
306
|
+
const decoyNodes = decoys.map(decoyData => {
|
|
307
|
+
const decoy = createElement('div')
|
|
308
|
+
const decoyImage = createElement('div')
|
|
309
|
+
setAttribute(decoy, 'class', `vobs-slider-captcha__decoy vobs-slider-captcha__decoy--${payload.shape ?? 'puzzle'}`)
|
|
310
|
+
setAttribute(decoyImage, 'class', 'vobs-slider-captcha__image')
|
|
311
|
+
setAttribute(decoy, 'style', `left: ${decoyData.x}px; top: ${decoyData.y}px; width: ${payload.pieceWidth}px; height: ${payload.pieceHeight}px; ${rotationStyle(decoyData.rotation)}`)
|
|
312
|
+
setAttribute(decoyImage, 'style', `${counterRotationStyle(decoyData.rotation)} ${imageAt(decoyData.x, decoyData.y)}`)
|
|
313
|
+
insertBefore(decoy, decoyImage, null)
|
|
314
|
+
return decoy
|
|
315
|
+
})
|
|
316
|
+
insertBefore(piece, pieceImage, null)
|
|
317
|
+
insertBefore(visual, target, null)
|
|
318
|
+
for (const decoy of decoyNodes) insertBefore(visual, decoy, null)
|
|
319
|
+
insertBefore(visual, piece, null)
|
|
320
|
+
if (context.status === 'error' || context.status === 'loading') {
|
|
321
|
+
const notice = createElement('p')
|
|
322
|
+
const isError = context.status === 'error'
|
|
323
|
+
setAttribute(notice, 'class', isError ? 'vobs-slider-captcha__error' : 'vobs-slider-captcha__notice')
|
|
324
|
+
setAttribute(notice, 'role', isError ? 'alert' : 'status')
|
|
325
|
+
insertBefore(notice, createText(isError
|
|
326
|
+
? readErrorMessage(props)
|
|
327
|
+
: readValue(props, 'loadingLabel', 'Refreshing captcha…')), null)
|
|
328
|
+
insertBefore(visual, notice, null)
|
|
329
|
+
}
|
|
330
|
+
insertBefore(track, trackPrompt, null)
|
|
331
|
+
insertBefore(track, handle, null)
|
|
332
|
+
insertBefore(wrapper, visual, null)
|
|
333
|
+
insertBefore(wrapper, track, null)
|
|
334
|
+
|
|
335
|
+
effect(() => {
|
|
336
|
+
const value = position.value
|
|
337
|
+
const pieceX = startX + value
|
|
338
|
+
onPositionChange(value)
|
|
339
|
+
setAttribute(track, 'data-has-moved', String(value > 0))
|
|
340
|
+
setAttribute(piece, 'style', `left: ${pieceX}px; top: ${payload.targetY}px; width: ${payload.pieceWidth}px; height: ${payload.pieceHeight}px; ${targetRotation}`)
|
|
341
|
+
setAttribute(handle, 'style', `left: calc(${value / Math.max(1, maxPosition) * 100}% - ${value / Math.max(1, maxPosition) * handleWidth}px)`)
|
|
342
|
+
setAttribute(handle, 'aria-valuemin', '0')
|
|
343
|
+
setAttribute(handle, 'aria-valuemax', String(maxPosition))
|
|
344
|
+
setAttribute(handle, 'aria-valuenow', String(Math.round(value)))
|
|
345
|
+
setProperty(handle, 'disabled', interactionDisabled)
|
|
346
|
+
setAttribute(wrapper, 'data-dragging', String(dragging.value))
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
for (const source of [piece, handle]) {
|
|
350
|
+
addEventListener(source, 'pointerdown', event => {
|
|
351
|
+
if (interactionDisabled) return
|
|
352
|
+
const pointer = event as PointerEvent
|
|
353
|
+
if (pointer.button !== undefined && pointer.button !== 0) return
|
|
354
|
+
dragging.value = true
|
|
355
|
+
startClientX = pointer.clientX
|
|
356
|
+
startPosition = position.value
|
|
357
|
+
startedAt = Date.now()
|
|
358
|
+
lastRecordedAt = 0
|
|
359
|
+
trail.length = 0
|
|
360
|
+
recordPoint(pointer)
|
|
361
|
+
const capture = source as Element & { setPointerCapture?: (pointerId: number) => void }
|
|
362
|
+
capture.setPointerCapture?.(pointer.pointerId)
|
|
363
|
+
})
|
|
364
|
+
addEventListener(source, 'pointermove', event => {
|
|
365
|
+
if (!dragging.value || interactionDisabled) return
|
|
366
|
+
const pointer = event as PointerEvent
|
|
367
|
+
const scale = getVisualScale(visual, payload.width)
|
|
368
|
+
position.value = clamp(startPosition + (pointer.clientX - startClientX) / scale, 0, maxPosition)
|
|
369
|
+
recordPoint(pointer)
|
|
370
|
+
})
|
|
371
|
+
addEventListener(source, 'pointerup', event => {
|
|
372
|
+
if (!dragging.value) return
|
|
373
|
+
recordPoint(event as PointerEvent, true)
|
|
374
|
+
dragging.value = false
|
|
375
|
+
submit()
|
|
376
|
+
})
|
|
377
|
+
addEventListener(source, 'pointercancel', () => {
|
|
378
|
+
dragging.value = false
|
|
379
|
+
position.value = 0
|
|
380
|
+
trail.length = 0
|
|
381
|
+
})
|
|
382
|
+
}
|
|
383
|
+
addEventListener(handle, 'keydown', event => {
|
|
384
|
+
if (interactionDisabled) return
|
|
385
|
+
const keyboard = event as KeyboardEvent
|
|
386
|
+
if (keyboard.key === 'ArrowLeft' || keyboard.key === 'ArrowRight') {
|
|
387
|
+
const direction = keyboard.key === 'ArrowRight' ? 1 : -1
|
|
388
|
+
position.value = clamp(position.value + direction * Math.max(1, maxPosition / 20), 0, maxPosition)
|
|
389
|
+
recordPoint({ clientX: position.value, clientY: 0 } as PointerEvent, true)
|
|
390
|
+
keyboard.preventDefault()
|
|
391
|
+
} else if (keyboard.key === 'Enter' || keyboard.key === ' ') {
|
|
392
|
+
submit()
|
|
393
|
+
keyboard.preventDefault()
|
|
394
|
+
}
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
onDispose(() => {
|
|
398
|
+
trail.length = 0
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
function recordPoint(pointer: PointerEvent, force = false): void {
|
|
402
|
+
const now = Date.now()
|
|
403
|
+
if (!force && now - lastRecordedAt < 20) return
|
|
404
|
+
trail.push({ x: pointer.clientX, y: pointer.clientY, t: now })
|
|
405
|
+
lastRecordedAt = now
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function submit(): void {
|
|
409
|
+
if (interactionDisabled || trail.length === 0) return
|
|
410
|
+
const now = Date.now()
|
|
411
|
+
const finalPoint = trail[trail.length - 1]
|
|
412
|
+
context.submit({
|
|
413
|
+
x: startX + position.value,
|
|
414
|
+
y: payload.targetY,
|
|
415
|
+
trail: trail.slice(),
|
|
416
|
+
duration: Math.max(0, (finalPoint?.t ?? now) - (startedAt || now)),
|
|
417
|
+
analysis: analyzeSliderTrail(trail),
|
|
418
|
+
deviceSignals: readValue(props, 'collectDeviceSignals', true)
|
|
419
|
+
? collectCaptchaDeviceSignals()
|
|
420
|
+
: undefined
|
|
421
|
+
})
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return wrapper
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function analyzeSliderTrail(trail: readonly SliderTrailPoint[]): SliderTrailAnalysis {
|
|
428
|
+
if (trail.length === 0) {
|
|
429
|
+
return {
|
|
430
|
+
pointCount: 0,
|
|
431
|
+
duration: 0,
|
|
432
|
+
distance: 0,
|
|
433
|
+
averageSpeed: 0,
|
|
434
|
+
maxSpeed: 0,
|
|
435
|
+
directionChanges: 0,
|
|
436
|
+
verticalTravel: 0,
|
|
437
|
+
averageInterval: 0,
|
|
438
|
+
looksHuman: false
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
let distance = 0
|
|
443
|
+
let verticalTravel = 0
|
|
444
|
+
let maxSpeed = 0
|
|
445
|
+
let directionChanges = 0
|
|
446
|
+
let previousDirection = 0
|
|
447
|
+
let intervalTotal = 0
|
|
448
|
+
let intervalCount = 0
|
|
449
|
+
|
|
450
|
+
for (let index = 1; index < trail.length; index++) {
|
|
451
|
+
const previous = trail[index - 1]
|
|
452
|
+
const current = trail[index]
|
|
453
|
+
const dx = current.x - previous.x
|
|
454
|
+
const dy = current.y - previous.y
|
|
455
|
+
const dt = Math.max(1, current.t - previous.t)
|
|
456
|
+
const segmentDistance = Math.hypot(dx, dy)
|
|
457
|
+
distance += segmentDistance
|
|
458
|
+
verticalTravel += Math.abs(dy)
|
|
459
|
+
maxSpeed = Math.max(maxSpeed, segmentDistance / dt * 1000)
|
|
460
|
+
intervalTotal += current.t - previous.t
|
|
461
|
+
intervalCount++
|
|
462
|
+
|
|
463
|
+
if (Math.abs(dx) >= 0.5) {
|
|
464
|
+
const direction = Math.sign(dx)
|
|
465
|
+
if (previousDirection !== 0 && direction !== previousDirection) directionChanges++
|
|
466
|
+
previousDirection = direction
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const duration = Math.max(0, trail[trail.length - 1].t - trail[0].t)
|
|
471
|
+
const averageSpeed = duration > 0 ? distance / duration * 1000 : 0
|
|
472
|
+
const averageInterval = intervalCount > 0 ? intervalTotal / intervalCount : 0
|
|
473
|
+
const looksHuman = trail.length >= 3
|
|
474
|
+
&& duration >= 120
|
|
475
|
+
&& duration <= 10_000
|
|
476
|
+
&& directionChanges > 0
|
|
477
|
+
|
|
478
|
+
return {
|
|
479
|
+
pointCount: trail.length,
|
|
480
|
+
duration,
|
|
481
|
+
distance,
|
|
482
|
+
averageSpeed,
|
|
483
|
+
maxSpeed,
|
|
484
|
+
directionChanges,
|
|
485
|
+
verticalTravel,
|
|
486
|
+
averageInterval,
|
|
487
|
+
looksHuman
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function readErrorMessage(props: SliderCaptchaProps): string {
|
|
492
|
+
const error = readValue<unknown>(props, 'error', undefined)
|
|
493
|
+
if (typeof error === 'string' && error) return error
|
|
494
|
+
if (error instanceof Error) return error.message
|
|
495
|
+
return readValue(props, 'errorLabel', 'Captcha verification failed.')
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function isSliderResult(value: unknown): value is SliderCaptchaResult {
|
|
499
|
+
if (value === null || typeof value !== 'object') return false
|
|
500
|
+
const result = value as Partial<SliderCaptchaResult>
|
|
501
|
+
return typeof result.x === 'number' && typeof result.y === 'number'
|
|
502
|
+
&& typeof result.duration === 'number' && Array.isArray(result.trail)
|
|
503
|
+
&& typeof result.analysis === 'object' && result.analysis !== null
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function getVisualScale(visual: Element, width: number): number {
|
|
507
|
+
const measured = visual.getBoundingClientRect?.().width ?? width
|
|
508
|
+
return measured > 0 ? measured / width : 1
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function quoteCssUrl(value: string): string {
|
|
512
|
+
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}` + '"'
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function clamp(value: number, min: number, max: number): number {
|
|
516
|
+
return Math.min(max, Math.max(min, value))
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function rotationStyle(rotation: number | undefined): string {
|
|
520
|
+
return rotation === undefined || rotation === 0 ? '' : `transform: rotate(${rotation}deg);`
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function readDecoys(payload: SliderCaptchaPayload): readonly SliderCaptchaDecoy[] {
|
|
524
|
+
if (payload.decoys !== undefined) return payload.decoys
|
|
525
|
+
if (payload.decoyX === undefined || payload.decoyY === undefined) return []
|
|
526
|
+
return [{ x: payload.decoyX, y: payload.decoyY, rotation: payload.decoyRotation }]
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function counterRotationStyle(rotation: number | undefined): string {
|
|
530
|
+
return rotation === undefined || rotation === 0 ? '' : `transform: rotate(${-rotation}deg);`
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function classNames(...values: readonly (string | undefined)[]): string {
|
|
534
|
+
return values.filter(Boolean).join(' ')
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function readValue<T>(props: object, name: string, fallback: T): T {
|
|
538
|
+
const value = Reflect.get(props, name)
|
|
539
|
+
if (value === undefined) return fallback
|
|
540
|
+
if (typeof value === 'function') return value() as T
|
|
541
|
+
if (isSignal<T>(value)) return value.value
|
|
542
|
+
return value as T
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function readString(props: object, name: string): string | undefined {
|
|
546
|
+
const value = readValue<string | undefined>(props, name, undefined)
|
|
547
|
+
return typeof value === 'string' ? value : undefined
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function readDataAndAriaProps(props: object): Record<string, string | number | boolean | undefined> {
|
|
551
|
+
const result: Record<string, string | number | boolean | undefined> = {}
|
|
552
|
+
for (const name of Object.keys(props)) {
|
|
553
|
+
if (name.startsWith('aria-') || name.startsWith('data-')) result[name] = readValue(props, name, undefined)
|
|
554
|
+
}
|
|
555
|
+
return result
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function isSignal<T>(value: unknown): value is Signal<T> {
|
|
559
|
+
return value !== null && typeof value === 'object'
|
|
560
|
+
&& 'value' in value && typeof (value as { dispose?: unknown }).dispose === 'function'
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function createSessionId(): string {
|
|
564
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID()
|
|
565
|
+
return `captcha-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
566
|
+
}
|