@strav/captcha 0.4.31 → 1.0.0-alpha.42
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/README.md +9 -0
- package/package.json +29 -20
- package/src/captcha_error.ts +14 -0
- package/src/captcha_manager.ts +132 -0
- package/src/captcha_provider.ts +49 -0
- package/src/challenges/{honeypot.ts → honeypot_challenge.ts} +6 -6
- package/src/challenges/{pow.ts → pow_challenge.ts} +7 -7
- package/src/challenges/{svg.ts → svg_challenge.ts} +8 -11
- package/src/http/captcha_middleware.ts +99 -0
- package/src/http/captcha_rule.ts +49 -0
- package/src/http/index.ts +5 -0
- package/src/http/refresh_routes.ts +59 -0
- package/src/index.ts +25 -42
- package/src/registry.ts +26 -22
- package/src/replay.ts +25 -0
- package/src/token.ts +78 -49
- package/src/types.ts +43 -37
- package/src/view/captcha_helper.ts +77 -0
- package/src/view/index.ts +3 -0
- package/src/challenges.ts +0 -89
- package/src/islands/pow.vue +0 -109
- package/src/islands/refresh.vue +0 -43
- package/src/middleware.ts +0 -125
- package/src/route.ts +0 -73
- package/src/validation_rule.ts +0 -31
- package/src/view_helper.ts +0 -105
- package/tsconfig.json +0 -5
package/src/islands/pow.vue
DELETED
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import { onMounted, ref } from 'vue'
|
|
3
|
-
|
|
4
|
-
interface Props {
|
|
5
|
-
challenge: string
|
|
6
|
-
difficulty: number
|
|
7
|
-
tokenField?: string
|
|
8
|
-
responseField?: string
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const props = withDefaults(defineProps<Props>(), {
|
|
12
|
-
tokenField: '_captcha',
|
|
13
|
-
responseField: '_captcha_answer',
|
|
14
|
-
})
|
|
15
|
-
|
|
16
|
-
const status = ref<'idle' | 'solving' | 'solved' | 'error'>('idle')
|
|
17
|
-
const elapsedMs = ref(0)
|
|
18
|
-
|
|
19
|
-
onMounted(() => {
|
|
20
|
-
void solve()
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
async function solve() {
|
|
24
|
-
status.value = 'solving'
|
|
25
|
-
const start = performance.now()
|
|
26
|
-
|
|
27
|
-
try {
|
|
28
|
-
const nonce = await findNonce(props.challenge, props.difficulty)
|
|
29
|
-
elapsedMs.value = Math.round(performance.now() - start)
|
|
30
|
-
writeAnswer(nonce)
|
|
31
|
-
status.value = 'solved'
|
|
32
|
-
} catch (err) {
|
|
33
|
-
console.error('[captcha/pow] failed', err)
|
|
34
|
-
status.value = 'error'
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Find a nonce N such that sha256(challenge + ':' + N) has at least
|
|
40
|
-
* `difficulty` leading zero bits. We try a counter — simple and easy to
|
|
41
|
-
* verify on the server with the same construction.
|
|
42
|
-
*
|
|
43
|
-
* Yields back to the event loop every 256 attempts so the page stays
|
|
44
|
-
* responsive on phones / weaker CPUs.
|
|
45
|
-
*/
|
|
46
|
-
async function findNonce(challenge: string, difficulty: number): Promise<string> {
|
|
47
|
-
if (typeof crypto === 'undefined' || !crypto.subtle) {
|
|
48
|
-
throw new Error('Web Crypto API unavailable')
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const encoder = new TextEncoder()
|
|
52
|
-
let nonce = 0
|
|
53
|
-
|
|
54
|
-
while (true) {
|
|
55
|
-
for (let i = 0; i < 256; i++) {
|
|
56
|
-
const candidate = String(nonce++)
|
|
57
|
-
const buffer = await crypto.subtle.digest(
|
|
58
|
-
'SHA-256',
|
|
59
|
-
encoder.encode(challenge + ':' + candidate)
|
|
60
|
-
)
|
|
61
|
-
if (leadingZeroBits(new Uint8Array(buffer)) >= difficulty) {
|
|
62
|
-
return candidate
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
// Yield to keep the UI alive
|
|
66
|
-
await new Promise(resolve => setTimeout(resolve, 0))
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function leadingZeroBits(bytes: Uint8Array): number {
|
|
71
|
-
let bits = 0
|
|
72
|
-
for (let i = 0; i < bytes.length; i++) {
|
|
73
|
-
const b = bytes[i]!
|
|
74
|
-
if (b === 0) {
|
|
75
|
-
bits += 8
|
|
76
|
-
continue
|
|
77
|
-
}
|
|
78
|
-
let n = b
|
|
79
|
-
while ((n & 0x80) === 0) {
|
|
80
|
-
bits++
|
|
81
|
-
n <<= 1
|
|
82
|
-
}
|
|
83
|
-
break
|
|
84
|
-
}
|
|
85
|
-
return bits
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Write the nonce into the form's hidden answer field. We look up the
|
|
90
|
-
* nearest enclosing form and find the input by name — robust against
|
|
91
|
-
* forms that re-render or move the widget around.
|
|
92
|
-
*/
|
|
93
|
-
function writeAnswer(nonce: string) {
|
|
94
|
-
const root = document.querySelector(`[data-vue="captcha/pow"]`)
|
|
95
|
-
const form = root?.closest('form')
|
|
96
|
-
if (!form) return
|
|
97
|
-
const input = form.querySelector(`input[name="${props.responseField}"]`)
|
|
98
|
-
if (input instanceof HTMLInputElement) input.value = nonce
|
|
99
|
-
}
|
|
100
|
-
</script>
|
|
101
|
-
|
|
102
|
-
<template>
|
|
103
|
-
<div class="captcha-pow" :data-status="status">
|
|
104
|
-
<span v-if="status === 'idle'" aria-hidden="true">⋯</span>
|
|
105
|
-
<span v-else-if="status === 'solving'">Verifying you're human…</span>
|
|
106
|
-
<span v-else-if="status === 'solved'" aria-live="polite">✓ Verified ({{ elapsedMs }}ms)</span>
|
|
107
|
-
<span v-else class="captcha-pow-error">Verification failed</span>
|
|
108
|
-
</div>
|
|
109
|
-
</template>
|
package/src/islands/refresh.vue
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
interface Props {
|
|
3
|
-
tokenField?: string
|
|
4
|
-
refreshUrl?: string
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
const props = withDefaults(defineProps<Props>(), {
|
|
8
|
-
tokenField: '_captcha',
|
|
9
|
-
refreshUrl: '/__captcha/svg',
|
|
10
|
-
})
|
|
11
|
-
|
|
12
|
-
async function refresh() {
|
|
13
|
-
const response = await fetch(props.refreshUrl, { headers: { Accept: 'image/svg+xml' } })
|
|
14
|
-
if (!response.ok) return
|
|
15
|
-
const newToken = response.headers.get('X-Captcha-Token')
|
|
16
|
-
const svgBody = await response.text()
|
|
17
|
-
|
|
18
|
-
// Find the SVG sibling — refresh button is a span inside the
|
|
19
|
-
// `.captcha-svg` wrapper that also holds the SSR-rendered SVG.
|
|
20
|
-
const root = document.querySelector('[data-vue="captcha/refresh"]')
|
|
21
|
-
const wrapper = root?.parentElement
|
|
22
|
-
const oldSvg = wrapper?.querySelector('svg')
|
|
23
|
-
if (oldSvg) {
|
|
24
|
-
const tmp = document.createElement('div')
|
|
25
|
-
tmp.innerHTML = svgBody
|
|
26
|
-
const newSvg = tmp.querySelector('svg')
|
|
27
|
-
if (newSvg) oldSvg.replaceWith(newSvg)
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
// Update the hidden token in the same <form>.
|
|
31
|
-
if (newToken) {
|
|
32
|
-
const form = wrapper?.closest('form')
|
|
33
|
-
const input = form?.querySelector(`input[name="${props.tokenField}"]`)
|
|
34
|
-
if (input instanceof HTMLInputElement) input.value = newToken
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
</script>
|
|
38
|
-
|
|
39
|
-
<template>
|
|
40
|
-
<button type="button" class="captcha-refresh" @click="refresh" aria-label="Refresh challenge">
|
|
41
|
-
↻
|
|
42
|
-
</button>
|
|
43
|
-
</template>
|
package/src/middleware.ts
DELETED
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
import type { Middleware, Context, Session } from '@strav/http'
|
|
2
|
-
import { CacheManager } from '@strav/kernel'
|
|
3
|
-
import type { CacheStore } from '@strav/kernel'
|
|
4
|
-
import type { CaptchaOptions, FailureReason, ChallengeName } from './types.ts'
|
|
5
|
-
import { DEFAULTS } from './types.ts'
|
|
6
|
-
import { verifyChallenge } from './challenges.ts'
|
|
7
|
-
import { consumeReplay } from './token.ts'
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Form/JSON guard. Place after `csrf()` and (optionally) after `rateLimit()`
|
|
11
|
-
* so cheap rejections don't even attempt CAPTCHA verification.
|
|
12
|
-
*
|
|
13
|
-
* @example
|
|
14
|
-
* router.post('/register', [
|
|
15
|
-
* rateLimit({ window: 60_000, max: 5 }),
|
|
16
|
-
* csrf(),
|
|
17
|
-
* captcha({ types: ['honeypot', 'pow'] }),
|
|
18
|
-
* ], handler)
|
|
19
|
-
*/
|
|
20
|
-
export function captcha(options: CaptchaOptions = {}): Middleware {
|
|
21
|
-
const types = (options.types ?? ['honeypot', 'pow']) as ChallengeName[]
|
|
22
|
-
const honeypotField = options.honeypotField ?? DEFAULTS.honeypotField
|
|
23
|
-
const tokenField = options.tokenField ?? DEFAULTS.tokenField
|
|
24
|
-
const responseField = options.responseField ?? DEFAULTS.responseField
|
|
25
|
-
|
|
26
|
-
// Lazy default — calling CacheManager.store at module-load would throw
|
|
27
|
-
// when the captcha import lands before the container is wired.
|
|
28
|
-
const resolveStore = (): CacheStore => options.store ?? CacheManager.store
|
|
29
|
-
|
|
30
|
-
const useHoneypot = types.includes('honeypot')
|
|
31
|
-
|
|
32
|
-
return async (ctx, next) => {
|
|
33
|
-
if (options.skip?.(ctx)) return next()
|
|
34
|
-
|
|
35
|
-
// Only guard state-changing methods. GET/HEAD shouldn't carry forms.
|
|
36
|
-
if (['GET', 'HEAD', 'OPTIONS'].includes(ctx.method)) return next()
|
|
37
|
-
|
|
38
|
-
const body = await readBody(ctx)
|
|
39
|
-
|
|
40
|
-
if (useHoneypot) {
|
|
41
|
-
const trap = body[honeypotField]
|
|
42
|
-
if (typeof trap === 'string' && trap.trim() !== '') {
|
|
43
|
-
return failure(ctx, 'honeypot_tripped', body, options)
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// If honeypot is the only requested type, we're done.
|
|
48
|
-
const challengeTypes = types.filter(t => t !== 'honeypot')
|
|
49
|
-
if (challengeTypes.length === 0) return next()
|
|
50
|
-
|
|
51
|
-
const token = typeof body[tokenField] === 'string' ? (body[tokenField] as string) : ''
|
|
52
|
-
if (!token) return failure(ctx, 'token_missing', body, options)
|
|
53
|
-
|
|
54
|
-
const result = verifyChallenge(token, body[responseField], body)
|
|
55
|
-
if (!result.ok) return failure(ctx, result.reason, body, options)
|
|
56
|
-
|
|
57
|
-
if (!challengeTypes.includes(result.payload.t)) {
|
|
58
|
-
// Token is valid but for a type this guard wasn't asked to accept —
|
|
59
|
-
// could happen if a challenge was issued for a different form.
|
|
60
|
-
return failure(ctx, 'token_invalid', body, options)
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const fresh = await consumeReplay(resolveStore(), result.payload)
|
|
64
|
-
if (!fresh) return failure(ctx, 'replayed', body, options)
|
|
65
|
-
|
|
66
|
-
return next()
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Read the parsed body without crashing on text/plain or empty bodies.
|
|
72
|
-
* Returns `{}` so callers can index without null-checks.
|
|
73
|
-
*/
|
|
74
|
-
async function readBody(ctx: Context): Promise<Record<string, unknown>> {
|
|
75
|
-
try {
|
|
76
|
-
const body = await ctx.body<unknown>()
|
|
77
|
-
return body && typeof body === 'object' ? (body as Record<string, unknown>) : {}
|
|
78
|
-
} catch {
|
|
79
|
-
return {}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
async function failure(
|
|
84
|
-
ctx: Context,
|
|
85
|
-
reason: FailureReason,
|
|
86
|
-
body: Record<string, unknown>,
|
|
87
|
-
options: CaptchaOptions
|
|
88
|
-
): Promise<Response> {
|
|
89
|
-
if (options.onFailure) return options.onFailure(ctx, reason)
|
|
90
|
-
|
|
91
|
-
if (wantsJson(ctx)) {
|
|
92
|
-
return ctx.json({ errors: { _captcha: [reason] } }, 422)
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Form path: flash errors + old input, redirect back to the referer
|
|
96
|
-
// (or root). Mirrors the convention used by `validate()` consumers.
|
|
97
|
-
const session = ctx.get<Session | undefined>('session')
|
|
98
|
-
if (session) {
|
|
99
|
-
session.flash('errors', { _captcha: [reason] })
|
|
100
|
-
session.flash('old', stripCaptchaFields(body, options))
|
|
101
|
-
await session.save()
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const referer = ctx.header('referer') ?? '/'
|
|
105
|
-
return ctx.redirect(referer, 303)
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function wantsJson(ctx: Context): boolean {
|
|
109
|
-
const accept = ctx.header('accept') ?? ''
|
|
110
|
-
if (accept.includes('application/json')) return true
|
|
111
|
-
if (ctx.header('x-requested-with') === 'XMLHttpRequest') return true
|
|
112
|
-
const contentType = ctx.header('content-type') ?? ''
|
|
113
|
-
return contentType.includes('application/json')
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function stripCaptchaFields(
|
|
117
|
-
body: Record<string, unknown>,
|
|
118
|
-
options: CaptchaOptions
|
|
119
|
-
): Record<string, unknown> {
|
|
120
|
-
const out = { ...body }
|
|
121
|
-
delete out[options.honeypotField ?? DEFAULTS.honeypotField]
|
|
122
|
-
delete out[options.tokenField ?? DEFAULTS.tokenField]
|
|
123
|
-
delete out[options.responseField ?? DEFAULTS.responseField]
|
|
124
|
-
return out
|
|
125
|
-
}
|
package/src/route.ts
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import type { Router, Context } from '@strav/http'
|
|
2
|
-
import { rateLimit } from '@strav/http'
|
|
3
|
-
import { issueChallenge } from './challenges.ts'
|
|
4
|
-
import { listChallengeTypes } from './registry.ts'
|
|
5
|
-
import type { ChallengeName } from './types.ts'
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Mount a refresh endpoint at `<prefix>/:type` so client-side widgets
|
|
9
|
-
* (e.g. the SVG refresh button) can request a new challenge without a
|
|
10
|
-
* full page reload. Stateless, no session required.
|
|
11
|
-
*
|
|
12
|
-
* Auto-rate-limited per IP — 30 issuances/minute by default. Override
|
|
13
|
-
* by passing your own `rateLimit()` middleware in `extraMiddleware`,
|
|
14
|
-
* or set `rateLimit: false` to disable (not recommended in production).
|
|
15
|
-
*
|
|
16
|
-
* @example
|
|
17
|
-
* mountCaptchaRoutes(router)
|
|
18
|
-
* // → GET /__captcha/svg, GET /__captcha/pow
|
|
19
|
-
*/
|
|
20
|
-
export function mountCaptchaRoutes(
|
|
21
|
-
router: Router,
|
|
22
|
-
options: {
|
|
23
|
-
prefix?: string
|
|
24
|
-
rateLimit?: false | { window?: number; max?: number }
|
|
25
|
-
extraMiddleware?: ReturnType<typeof rateLimit>[]
|
|
26
|
-
} = {}
|
|
27
|
-
): void {
|
|
28
|
-
const prefix = options.prefix ?? '/__captcha'
|
|
29
|
-
|
|
30
|
-
const middleware = []
|
|
31
|
-
if (options.extraMiddleware?.length) {
|
|
32
|
-
middleware.push(...options.extraMiddleware)
|
|
33
|
-
} else if (options.rateLimit !== false) {
|
|
34
|
-
middleware.push(
|
|
35
|
-
rateLimit({
|
|
36
|
-
window: options.rateLimit?.window ?? 60_000,
|
|
37
|
-
max: options.rateLimit?.max ?? 30,
|
|
38
|
-
})
|
|
39
|
-
)
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
router.group({ prefix, middleware }, r => {
|
|
43
|
-
r.get('/:type', async (ctx: Context) => {
|
|
44
|
-
const type = ctx.params.type as ChallengeName
|
|
45
|
-
if (!listChallengeTypes().includes(type)) {
|
|
46
|
-
return ctx.json({ error: 'Unknown challenge type' }, 404)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const issued = issueChallenge(type)
|
|
50
|
-
|
|
51
|
-
// SVG type returns the image directly — easy <img src=…> embed.
|
|
52
|
-
// Other types return JSON so the client can hydrate its own widget.
|
|
53
|
-
if (type === 'svg' && issued.html) {
|
|
54
|
-
return new Response(issued.html, {
|
|
55
|
-
status: 200,
|
|
56
|
-
headers: {
|
|
57
|
-
'Content-Type': 'image/svg+xml',
|
|
58
|
-
'Cache-Control': 'no-store',
|
|
59
|
-
// Pass the token via header so the client can stash it without
|
|
60
|
-
// re-parsing the SVG body. Browsers expose this on fetch responses.
|
|
61
|
-
'X-Captcha-Token': issued.token,
|
|
62
|
-
},
|
|
63
|
-
})
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
return ctx.json({
|
|
67
|
-
token: issued.token,
|
|
68
|
-
props: issued.props,
|
|
69
|
-
html: issued.html,
|
|
70
|
-
})
|
|
71
|
-
})
|
|
72
|
-
})
|
|
73
|
-
}
|
package/src/validation_rule.ts
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import type { Rule } from '@strav/http'
|
|
2
|
-
import { verifyChallenge } from './challenges.ts'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Validation rule for handlers that prefer `validate()` integration over
|
|
6
|
-
* the middleware. Bind the user's response (and optionally the full body
|
|
7
|
-
* for honeypot/extension checks) when constructing the rule — closure
|
|
8
|
-
* keeps it per-request, no shared mutable state.
|
|
9
|
-
*
|
|
10
|
-
* NOTE: this rule does not perform replay prevention — `validate()` is
|
|
11
|
-
* sync and has no place to read a cache store. For replay-safe checks
|
|
12
|
-
* use the `captcha()` middleware. The rule fits idempotent endpoints
|
|
13
|
-
* where double-submit is harmless.
|
|
14
|
-
*
|
|
15
|
-
* @example
|
|
16
|
-
* const body = await ctx.body<Record<string, unknown>>()
|
|
17
|
-
* const { errors } = validate(body, {
|
|
18
|
-
* _captcha: [captchaRule(body._captcha_answer, body)],
|
|
19
|
-
* email: [required(), email()],
|
|
20
|
-
* })
|
|
21
|
-
*/
|
|
22
|
-
export function captchaRule(response: unknown, body?: Record<string, unknown>): Rule {
|
|
23
|
-
return {
|
|
24
|
-
name: 'captcha',
|
|
25
|
-
validate(value) {
|
|
26
|
-
const token = typeof value === 'string' ? value : ''
|
|
27
|
-
const result = verifyChallenge(token, response, body ?? {})
|
|
28
|
-
return result.ok ? null : `captcha.${result.reason}`
|
|
29
|
-
},
|
|
30
|
-
}
|
|
31
|
-
}
|
package/src/view_helper.ts
DELETED
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
import { ViewEngine } from '@strav/view'
|
|
2
|
-
import { issueChallenge } from './challenges.ts'
|
|
3
|
-
import { DEFAULTS } from './types.ts'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Default field names. Kept on the helper itself so a single config
|
|
7
|
-
* point flows into both middleware and template rendering — change
|
|
8
|
-
* these and `@captcha` re-emits matching field names automatically.
|
|
9
|
-
*/
|
|
10
|
-
const config = {
|
|
11
|
-
honeypotField: DEFAULTS.honeypotField,
|
|
12
|
-
tokenField: DEFAULTS.tokenField,
|
|
13
|
-
responseField: DEFAULTS.responseField,
|
|
14
|
-
difficulty: DEFAULTS.difficulty,
|
|
15
|
-
ttlMinutes: DEFAULTS.ttlMinutes,
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Optionally configure helper-side defaults so the markup matches the
|
|
20
|
-
* server-side `captcha()` middleware options. Required only when an app
|
|
21
|
-
* customizes field names — defaults match middleware defaults.
|
|
22
|
-
*
|
|
23
|
-
* @example
|
|
24
|
-
* configureCaptchaHelper({ honeypotField: 'phone_number' })
|
|
25
|
-
*/
|
|
26
|
-
export function configureCaptchaHelper(opts: Partial<typeof config>): void {
|
|
27
|
-
Object.assign(config, opts)
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Render the form fragment for a challenge. Returns ready-to-emit HTML
|
|
32
|
-
* including the honeypot, the hidden token, and (for visible types) the
|
|
33
|
-
* answer input + island/SVG.
|
|
34
|
-
*
|
|
35
|
-
* Synchronous: `issueChallenge` is sync, the renderer just concatenates
|
|
36
|
-
* strings — safe to call from inside the template render loop.
|
|
37
|
-
*/
|
|
38
|
-
export function captchaHelper(variant?: string): string {
|
|
39
|
-
const honeypot = renderHoneypot()
|
|
40
|
-
|
|
41
|
-
if (!variant || variant === 'honeypot') return honeypot
|
|
42
|
-
|
|
43
|
-
if (variant === 'pow') {
|
|
44
|
-
const issued = issueChallenge('pow', { difficulty: config.difficulty, ttlMinutes: config.ttlMinutes })
|
|
45
|
-
const props = JSON.stringify({
|
|
46
|
-
...issued.props,
|
|
47
|
-
tokenField: config.tokenField,
|
|
48
|
-
responseField: config.responseField,
|
|
49
|
-
}).replace(/'/g, ''')
|
|
50
|
-
return (
|
|
51
|
-
honeypot +
|
|
52
|
-
`<input type="hidden" name="${attr(config.tokenField)}" value="${attr(issued.token)}">` +
|
|
53
|
-
`<input type="hidden" name="${attr(config.responseField)}" value="">` +
|
|
54
|
-
`<div data-vue="captcha/pow" data-props='${props}'></div>`
|
|
55
|
-
)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
if (variant === 'svg') {
|
|
59
|
-
const issued = issueChallenge('svg', { ttlMinutes: config.ttlMinutes })
|
|
60
|
-
const refreshProps = JSON.stringify({ tokenField: config.tokenField }).replace(/'/g, ''')
|
|
61
|
-
return (
|
|
62
|
-
honeypot +
|
|
63
|
-
`<input type="hidden" name="${attr(config.tokenField)}" value="${attr(issued.token)}">` +
|
|
64
|
-
`<div class="captcha-svg">` +
|
|
65
|
-
(issued.html ?? '') +
|
|
66
|
-
// Sibling-not-child: the Vue island replaces this element's content,
|
|
67
|
-
// but the SVG above stays untouched until the user clicks refresh.
|
|
68
|
-
`<span data-vue="captcha/refresh" data-props='${refreshProps}'></span>` +
|
|
69
|
-
`</div>` +
|
|
70
|
-
`<input type="text" name="${attr(config.responseField)}" autocomplete="off" ` +
|
|
71
|
-
`inputmode="latin" autocapitalize="characters" required aria-label="Type the characters shown">`
|
|
72
|
-
)
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
throw new Error(`@captcha: unknown variant "${variant}" (expected 'pow' | 'svg' | 'honeypot' or none)`)
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function renderHoneypot(): string {
|
|
79
|
-
const name = attr(config.honeypotField)
|
|
80
|
-
// Triple defense: aria-hidden, tabindex=-1, off-screen via inline style.
|
|
81
|
-
// Don't use `display:none` — some bots skip those fields.
|
|
82
|
-
return (
|
|
83
|
-
`<div aria-hidden="true" style="position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden">` +
|
|
84
|
-
`<label for="${name}">Leave this field empty</label>` +
|
|
85
|
-
`<input type="text" id="${name}" name="${name}" tabindex="-1" autocomplete="off" value="">` +
|
|
86
|
-
`</div>`
|
|
87
|
-
)
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function attr(s: string): string {
|
|
91
|
-
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<')
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Wire `__captcha(variant)` into the view engine so `@captcha` works.
|
|
96
|
-
* Call once at boot, after ViewEngine is constructed.
|
|
97
|
-
*
|
|
98
|
-
* @example
|
|
99
|
-
* import { installCaptchaHelpers } from '@strav/captcha'
|
|
100
|
-
* // …after ViewEngine is registered in the container
|
|
101
|
-
* installCaptchaHelpers()
|
|
102
|
-
*/
|
|
103
|
-
export function installCaptchaHelpers(): void {
|
|
104
|
-
ViewEngine.setGlobal('__captcha', captchaHelper)
|
|
105
|
-
}
|