Files
kycnotme/web/src/lib/captcha.ts
2025-05-19 10:23:36 +00:00

170 lines
5.1 KiB
TypeScript

import { createHash } from 'crypto'
import { createCanvas } from 'canvas'
export const CAPTCHA_LENGTH = 6
const CAPTCHA_CHARS = 'ABCDEFGHIJKMNOPRSTUVWXYZ123456789' // Notice that ambiguous characters are removed
/** Hash a captcha value */
function hashCaptchaValue(value: string): string {
return createHash('sha256').update(value).digest('hex')
}
/** Generate a captcha image as a data URI */
export function generateCaptchaImage(text: string) {
const width = 144
const height = 48
const canvas = createCanvas(width, height)
const ctx = canvas.getContext('2d')
// Fill background with gradient
const gradient = ctx.createLinearGradient(0, 0, width, height)
gradient.addColorStop(0, '#1a202c')
gradient.addColorStop(1, '#2d3748')
ctx.fillStyle = gradient
ctx.fillRect(0, 0, width, height)
// Add grid pattern background
ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)'
ctx.lineWidth = 1
for (let i = 0; i < width; i += 10) {
ctx.beginPath()
ctx.moveTo(i, 0)
ctx.lineTo(i, height)
ctx.stroke()
}
for (let i = 0; i < height; i += 10) {
ctx.beginPath()
ctx.moveTo(0, i)
ctx.lineTo(width, i)
ctx.stroke()
}
// Add wavy lines
for (let i = 0; i < 3; i++) {
const r = Math.floor(Math.random() * 200 + 55).toString()
const g = Math.floor(Math.random() * 200 + 55).toString()
const b = Math.floor(Math.random() * 200 + 55).toString()
ctx.strokeStyle = 'rgba(' + r + ', ' + g + ', ' + b + ', 0.5)'
ctx.lineWidth = Math.random() * 3 + 1
ctx.beginPath()
const startY = Math.random() * height
let curveX = 0
let curveY = startY
ctx.moveTo(curveX, curveY)
while (curveX < width) {
const nextX = curveX + Math.random() * 20 + 10
const nextY = startY + Math.sin(curveX / 20) * (Math.random() * 15 + 5)
ctx.quadraticCurveTo(curveX + (nextX - curveX) / 2, curveY + Math.random() * 20 - 10, nextX, nextY)
curveX = nextX
curveY = nextY
}
ctx.stroke()
}
// Add random dots
for (let i = 0; i < 100; i++) {
const r = Math.floor(Math.random() * 200 + 55).toString()
const g = Math.floor(Math.random() * 200 + 55).toString()
const b = Math.floor(Math.random() * 200 + 55).toString()
const alpha = (Math.random() * 0.5 + 0.1).toString()
ctx.fillStyle = 'rgba(' + r + ', ' + g + ', ' + b + ', ' + alpha + ')'
ctx.beginPath()
ctx.arc(Math.random() * width, Math.random() * height, Math.random() * 2 + 1, 0, Math.PI * 2)
ctx.fill()
}
// Draw captcha text with shadow and more distortion
ctx.shadowColor = 'rgba(0, 0, 0, 0.5)'
ctx.shadowBlur = 3
ctx.shadowOffsetX = 2
ctx.shadowOffsetY = 2
// Calculate text positioning
const fontSize = Math.floor(height * 0.55)
const padding = 15
const charWidth = (width - padding * 2) / text.length
// Draw each character with various effects
for (let i = 0; i < text.length; i++) {
const r = Math.floor(Math.random() * 100 + 155).toString()
const g = Math.floor(Math.random() * 100 + 155).toString()
const b = Math.floor(Math.random() * 100 + 155).toString()
ctx.fillStyle = 'rgb(' + r + ', ' + g + ', ' + b + ')'
// Vary font style for each character
const fontStyle = Math.random() > 0.5 ? 'bold' : 'normal'
const fontFamily = Math.random() > 0.5 ? 'monospace' : 'Arial'
const fontSizeStr = fontSize.toString()
ctx.font = fontStyle + ' ' + fontSizeStr + 'px ' + fontFamily
// Position with variations
const xPos = padding + i * charWidth + (Math.random() * 8 - 4)
const yPos = height * 0.6 + (Math.random() * 12 - 6)
const rotation = Math.random() * 0.6 - 0.3
const scale = 0.8 + Math.random() * 0.4
ctx.save()
ctx.translate(xPos, yPos)
ctx.rotate(rotation)
ctx.scale(scale, scale)
// Add slight perspective effect
if (Math.random() > 0.7) {
ctx.transform(1, 0, 0.1, 1, 0, 0)
} else if (Math.random() > 0.5) {
ctx.transform(1, 0, -0.1, 1, 0, 0)
}
ctx.fillText(text.charAt(i), 0, 0)
ctx.restore()
}
// Add some noise on top
for (let x = 0; x < width; x += 3) {
for (let y = 0; y < height; y += 3) {
if (Math.random() > 0.95) {
const alpha = (Math.random() * 0.2 + 0.1).toString()
ctx.fillStyle = 'rgba(255, 255, 255, ' + alpha + ')'
ctx.fillRect(x, y, 2, 2)
}
}
}
return {
src: canvas.toDataURL('image/png'),
width,
height,
format: 'png',
} as const satisfies ImageMetadata
}
/** Generate a new captcha with solution, its hash, and image data URI */
export function generateCaptcha() {
const solution = Array.from({ length: CAPTCHA_LENGTH }, () =>
CAPTCHA_CHARS.charAt(Math.floor(Math.random() * CAPTCHA_CHARS.length))
).join('')
return {
solution,
solutionHash: hashCaptchaValue(solution),
image: generateCaptchaImage(solution),
}
}
/** Verify a captcha input against the expected hash */
export function verifyCaptcha(value: string, solutionHash: string): boolean {
const correctedValue = value
.toUpperCase()
.replace(/[^A-Z0-9]/g, '')
.replace(/0/g, 'O')
.replace(/Q/g, 'O')
.replace(/L/g, 'I')
const valueHash = hashCaptchaValue(correctedValue)
return valueHash === solutionHash
}