博客开放评论后,常会有一些垃圾评论充斥后台。对于此类“机器人留言”,要在夹杂着正常评论的数千条数据当中进行整理、删除,着实是不小的工作量。

解决之道有以下几种:一是设置后台审核功能,或关闭匿名评论,审核通过后才予以公开;二则设置关键词,含有敏感词的评论一律屏蔽;三是设置黑名单(IP、User-Agent等),屏蔽访问;四是设置验证码,屏蔽机器人用户。其他的还包括:实名、积分/信用体系等。诸此种种,难说任何一种能够单独防御所有非法行为。结合成本、用户体验、可行性等综合考虑,多半是数种措施并举,尤其是此类小型站点。验证码便是广为使用的有效措施之一。

要在Node.js中使用验证码,似乎并没有太多成熟的实践。在npm中搜索captcha,大部分似乎都是demo性质的项目,或者并不满足自身的需求。受到一些社区文章的启示,大致清楚了验证码的整个过程及生成的原理:无非生成一串随机字符,并随机显示在固定尺寸的小图片上,并加上噪点;而后保存字符串在session中,与用户输入的内容进行匹配和拦截。

于是,尝试着自己实现一个方案。考虑到先前在实现图片上传加水印时,引入了gm库,结合网上gm+其他语言的实现,决定同样采用gm来实现验证码。

原理如下:

  1. 随机生成4位字符(字母、数字);
  2. 然后gm设置image的背景、填充色、字体大小等;
  3. 将文本绘制到图片上
  4. 通过正弦曲线进行扭曲
  5. 绘制噪声曲线/直线/点

其中,为增加随机性,字体大小、文本位置、正弦曲线的振幅、波长、噪声直线的起讫点均为一定范围内的随机值。

最后,通过req.session.captcha保存验证码字符,通过gm.write输出图片,前端通过请求该图片地址显示在页面上。

但,此方案存在一个问题,以图片形式生成的验证码,需要保证唯一性,并且和会话关联,在重新生成或者会话结束后还需要将过期的验证码销毁。考虑到前端在做工程化时经常会将小图片转为data格式,因此考虑是否也可将验证码从图片转为data格式,避免生成文件。

恰好,gm提供输出Buffer格式,而Buffer支持格式化为base64编码。因此,gm完美支持生成data格式的图片。返回到前端后,经测试,完美解决此问题,且,对于验证码刷新的支持几乎没有任何额外的工作。

完整代码如下(见:GitHub):

javascriptCopy code
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
function getRandomText() { const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; const charsLen = chars.length; let text = ''; for (let i = 0; i < 4; i += 1) { text += chars[Math.floor(Math.random() * charsLen)]; } return text; } function createCaptcha(req, res, next) { const imgHeight = 32; const imgWidth = 80; const fontSizeRange = [16, 22]; const blankHeightRange = [16, 24]; const blankHeight = Math.round(Math.random() * (blankHeightRange[1] - blankHeightRange[0]) * 10) / 10 + blankHeightRange[0]; const lineYRange = [2, 28]; const marginLeftRange = [0, 15]; const waveAmplitude = (imgHeight - blankHeight) / 2; const waveLengthRange = [36, 60]; const captchaText = getRandomText(); const textGravity = ['West', 'East']; let gmImg = gm(imgWidth, blankHeight, '#ddd'); gmImg.background('#ddd') .fill('#000') .fontSize(Math.round(Math.random() * (fontSizeRange[1] - fontSizeRange[0]) * 10) / 10 + fontSizeRange[0]) .gravity('Center') .drawText(Math.round(Math.random() * (marginLeftRange[1] - marginLeftRange[0]) * 10) / 10 + marginLeftRange[0], 0, captchaText, textGravity[Math.round(Math.random())]) .wave(waveAmplitude, Math.round(Math.random() * (waveLengthRange[1] - waveLengthRange[0]) * 10) / 10 + waveLengthRange[0]) .drawLine(0, Math.random() * (lineYRange[1] - lineYRange[0]) + lineYRange[0], imgWidth, Math.random() * (lineYRange[1] - lineYRange[0]) + lineYRange[0]) .drawLine(0, Math.random() * (lineYRange[1] - lineYRange[0]) + lineYRange[0], imgWidth, Math.random() * (lineYRange[1] - lineYRange[0]) + lineYRange[0]) .drawLine(0, Math.random() * (lineYRange[1] - lineYRange[0]) + lineYRange[0], imgWidth, Math.random() * (lineYRange[1] - lineYRange[0]) + lineYRange[0]) .toBuffer('.png', (err, buffers) => { if (err) { err.code = 500; return next(err); } req.session.captcha = captchaText; res.send('data:image/png;base64,' + buffers.toString('base64')); }); }
function getRandomText() { const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; const charsLen = chars.length; let text = ''; for (let i = 0; i < 4; i += 1) { text += chars[Math.floor(Math.random() * charsLen)]; } return text; } function createCaptcha(req, res, next) { const imgHeight = 32; const imgWidth = 80; const fontSizeRange = [16, 22]; const blankHeightRange = [16, 24]; const blankHeight = Math.round(Math.random() * (blankHeightRange[1] - blankHeightRange[0]) * 10) / 10 + blankHeightRange[0]; const lineYRange = [2, 28]; const marginLeftRange = [0, 15]; const waveAmplitude = (imgHeight - blankHeight) / 2; const waveLengthRange = [36, 60]; const captchaText = getRandomText(); const textGravity = ['West', 'East']; let gmImg = gm(imgWidth, blankHeight, '#ddd'); gmImg.background('#ddd') .fill('#000') .fontSize(Math.round(Math.random() * (fontSizeRange[1] - fontSizeRange[0]) * 10) / 10 + fontSizeRange[0]) .gravity('Center') .drawText(Math.round(Math.random() * (marginLeftRange[1] - marginLeftRange[0]) * 10) / 10 + marginLeftRange[0], 0, captchaText, textGravity[Math.round(Math.random())]) .wave(waveAmplitude, Math.round(Math.random() * (waveLengthRange[1] - waveLengthRange[0]) * 10) / 10 + waveLengthRange[0]) .drawLine(0, Math.random() * (lineYRange[1] - lineYRange[0]) + lineYRange[0], imgWidth, Math.random() * (lineYRange[1] - lineYRange[0]) + lineYRange[0]) .drawLine(0, Math.random() * (lineYRange[1] - lineYRange[0]) + lineYRange[0], imgWidth, Math.random() * (lineYRange[1] - lineYRange[0]) + lineYRange[0]) .drawLine(0, Math.random() * (lineYRange[1] - lineYRange[0]) + lineYRange[0], imgWidth, Math.random() * (lineYRange[1] - lineYRange[0]) + lineYRange[0]) .toBuffer('.png', (err, buffers) => { if (err) { err.code = 500; return next(err); } req.session.captcha = captchaText; res.send('data:image/png;base64,' + buffers.toString('base64')); }); }