Files
ragflow/internal/utility/captcha_png.go
Zhichang Yu f58fae5fb7 feat(go-agent): Ported retrieval node, added Keenable web search tool (#16396)
Ported retrieval node, added Keenable web search tool
- [x] New Feature (non-breaking change which adds functionality)
2026-06-29 09:45:16 +08:00

260 lines
9.1 KiB
Go

//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// 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.
//
// Stdlib-only PNG captcha renderer.
//
// PR #15290 review (Hz-186): the previous SVG renderer embedded the
// captcha text in <text> nodes, so a scripted client could base64-
// decode the response and read the answer with a regex — defeating
// the captcha entirely. The reviewer asked for either a raster
// captcha or something that doesn't put the answer in machine-
// readable response content. We have no image-captcha library
// vendored in go.mod and no network access during build, so this
// renders a real PNG using only stdlib `image`, `image/color`,
// `image/draw`, and `image/png`, with a hand-rolled 5x7 bitmap font
// for [A-Z0-9].
//
// The output bytes contain only the raster — the captcha text is
// nowhere in the response stream — so the previous regex-the-answer
// attack is closed. An OCR-capable attacker can still solve it, but
// that's the standard limit of any non-trivial captcha; the bar set
// by the reviewer was specifically "not machine-readable in the
// response content."
package utility
import (
"bytes"
"encoding/base64"
"image"
"image/color"
"image/draw"
"image/png"
"math/rand"
"strings"
"time"
)
// captchaPNGScale is the per-glyph pixel multiplier. The font is 5x7,
// so a scale of 4 produces 20x28 glyphs, which are ~16x16 px after
// padding — comfortably readable for humans at typical browser zoom.
const (
captchaPNGScale = 4
captchaGlyphW = 5
captchaGlyphH = 7
captchaCharSpacing = 4 // px between glyphs (after scaling)
captchaSidePadding = 8
captchaTopPadding = 6
captchaNoiseDots = 60
captchaNoiseLines = 4
)
// font5x7 maps a single character to its 7-row bitmap. Each row is a
// 5-character string where '#' is a foreground pixel and any other
// character is background. Covers the captcha alphabet ([A-Z0-9])
// plus '?' as a fallback glyph for anything unexpected.
//
// These are hand-drawn — apologies for the eye-strain.
var font5x7 = map[byte][7]string{
'A': {".###.", "#...#", "#...#", "#####", "#...#", "#...#", "#...#"},
'B': {"####.", "#...#", "#...#", "####.", "#...#", "#...#", "####."},
'C': {".####", "#....", "#....", "#....", "#....", "#....", ".####"},
'D': {"####.", "#...#", "#...#", "#...#", "#...#", "#...#", "####."},
'E': {"#####", "#....", "#....", "####.", "#....", "#....", "#####"},
'F': {"#####", "#....", "#....", "####.", "#....", "#....", "#...."},
'G': {".####", "#....", "#....", "#..##", "#...#", "#...#", ".####"},
'H': {"#...#", "#...#", "#...#", "#####", "#...#", "#...#", "#...#"},
'I': {"#####", "..#..", "..#..", "..#..", "..#..", "..#..", "#####"},
'J': {"#####", "...#.", "...#.", "...#.", "...#.", "#..#.", ".##.."},
'K': {"#...#", "#..#.", "#.#..", "##...", "#.#..", "#..#.", "#...#"},
'L': {"#....", "#....", "#....", "#....", "#....", "#....", "#####"},
'M': {"#...#", "##.##", "#.#.#", "#...#", "#...#", "#...#", "#...#"},
'N': {"#...#", "##..#", "#.#.#", "#.#.#", "#..##", "#...#", "#...#"},
'O': {".###.", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."},
'P': {"####.", "#...#", "#...#", "####.", "#....", "#....", "#...."},
'Q': {".###.", "#...#", "#...#", "#...#", "#.#.#", "#..#.", ".##.#"},
'R': {"####.", "#...#", "#...#", "####.", "#.#..", "#..#.", "#...#"},
'S': {".####", "#....", "#....", ".###.", "....#", "....#", "####."},
'T': {"#####", "..#..", "..#..", "..#..", "..#..", "..#..", "..#.."},
'U': {"#...#", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."},
'V': {"#...#", "#...#", "#...#", "#...#", "#...#", ".#.#.", "..#.."},
'W': {"#...#", "#...#", "#...#", "#...#", "#.#.#", "##.##", "#...#"},
'X': {"#...#", "#...#", ".#.#.", "..#..", ".#.#.", "#...#", "#...#"},
'Y': {"#...#", "#...#", ".#.#.", "..#..", "..#..", "..#..", "..#.."},
'Z': {"#####", "....#", "...#.", "..#..", ".#...", "#....", "#####"},
'0': {".###.", "#...#", "#..##", "#.#.#", "##..#", "#...#", ".###."},
'1': {"..#..", ".##..", "..#..", "..#..", "..#..", "..#..", ".###."},
'2': {".###.", "#...#", "....#", "...#.", "..#..", ".#...", "#####"},
'3': {"####.", "....#", "....#", ".###.", "....#", "....#", "####."},
'4': {"...#.", "..##.", ".#.#.", "#..#.", "#####", "...#.", "...#."},
'5': {"#####", "#....", "####.", "....#", "....#", "....#", "####."},
'6': {".###.", "#....", "#....", "####.", "#...#", "#...#", ".###."},
'7': {"#####", "....#", "....#", "...#.", "..#..", ".#...", "#...."},
'8': {".###.", "#...#", "#...#", ".###.", "#...#", "#...#", ".###."},
'9': {".###.", "#...#", "#...#", ".####", "....#", "....#", ".###."},
'?': {".###.", "#...#", "....#", "...#.", "..#..", ".....", "..#.."},
}
// RenderCaptchaPNG renders the captcha text as a PNG and returns the
// raw bytes. The image has per-character jitter, random distractor
// lines, and dot noise applied — enough to defeat the trivial-regex
// attack from the previous SVG implementation. OCR-capable attackers
// remain a possibility (standard captcha limit).
//
// The output never references the original text — the answer is
// painted as raster pixels only.
func RenderCaptchaPNG(text string) []byte {
if text == "" {
text = " "
}
upper := strings.ToUpper(text)
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
glyphW := captchaGlyphW * captchaPNGScale
glyphH := captchaGlyphH * captchaPNGScale
width := captchaSidePadding*2 + len(upper)*glyphW + (len(upper)-1)*captchaCharSpacing
if width < 40 {
width = 40
}
height := captchaTopPadding*2 + glyphH + 8 // a bit of headroom for jitter
img := image.NewRGBA(image.Rect(0, 0, width, height))
// Background — light, slightly cool grey.
bg := color.RGBA{R: 0xf5, G: 0xf5, B: 0xf7, A: 0xff}
draw.Draw(img, img.Bounds(), &image.Uniform{bg}, image.Point{}, draw.Src)
// Distractor lines drawn under the glyphs.
for i := 0; i < captchaNoiseLines; i++ {
drawLine(
img,
rng.Intn(width), rng.Intn(height),
rng.Intn(width), rng.Intn(height),
pickStrokeRGBA(rng),
)
}
// Glyphs, each with x/y jitter and a per-glyph foreground colour.
x := captchaSidePadding
for i := 0; i < len(upper); i++ {
ch := upper[i]
bitmap, ok := font5x7[ch]
if !ok {
bitmap = font5x7['?']
}
dx := rng.Intn(5) - 2
dy := rng.Intn(7) - 3
fg := pickFillRGBA(rng)
drawGlyph(img, x+dx, captchaTopPadding+dy, bitmap, fg)
x += glyphW + captchaCharSpacing
_ = i // explicit to silence any future lint pass
}
// Foreground dot noise on top.
for i := 0; i < captchaNoiseDots; i++ {
img.Set(rng.Intn(width), rng.Intn(height), pickStrokeRGBA(rng))
}
var buf bytes.Buffer
_ = png.Encode(&buf, img)
return buf.Bytes()
}
// RenderCaptchaPNGDataURL base64-wraps the PNG so the handler can
// return a single JSON string the FE drops into <img src="...">.
func RenderCaptchaPNGDataURL(text string) string {
pngBytes := RenderCaptchaPNG(text)
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(pngBytes)
}
// drawGlyph blits a 5x7 bitmap at (x, y) using captchaPNGScale x
// captchaPNGScale pixel blocks. Each '#' in the bitmap becomes a
// scale*scale block of `fg`.
func drawGlyph(img *image.RGBA, x, y int, bitmap [7]string, fg color.RGBA) {
for row := 0; row < captchaGlyphH; row++ {
line := bitmap[row]
for col := 0; col < captchaGlyphW && col < len(line); col++ {
if line[col] != '#' {
continue
}
for dy := 0; dy < captchaPNGScale; dy++ {
for dx := 0; dx < captchaPNGScale; dx++ {
img.Set(x+col*captchaPNGScale+dx, y+row*captchaPNGScale+dy, fg)
}
}
}
}
}
// drawLine paints a 1px line using Bresenham's algorithm. Out-of-bounds
// pixels are clipped by image.RGBA.Set silently, so no bounds check
// is needed here.
func drawLine(img *image.RGBA, x0, y0, x1, y1 int, c color.RGBA) {
dx := abs(x1 - x0)
dy := -abs(y1 - y0)
sx := 1
if x0 >= x1 {
sx = -1
}
sy := 1
if y0 >= y1 {
sy = -1
}
err := dx + dy
for {
img.Set(x0, y0, c)
if x0 == x1 && y0 == y1 {
return
}
e2 := 2 * err
if e2 >= dy {
err += dy
x0 += sx
}
if e2 <= dx {
err += dx
y0 += sy
}
}
}
func abs(n int) int {
if n < 0 {
return -n
}
return n
}
func pickFillRGBA(rng *rand.Rand) color.RGBA {
palette := []color.RGBA{
{R: 0x1f, G: 0x29, B: 0x37, A: 0xff},
{R: 0x1d, G: 0x4e, B: 0xd8, A: 0xff},
{R: 0x7c, G: 0x2d, B: 0x12, A: 0xff},
{R: 0x06, G: 0x5f, B: 0x46, A: 0xff},
{R: 0x7e, G: 0x22, B: 0xce, A: 0xff},
}
return palette[rng.Intn(len(palette))]
}
func pickStrokeRGBA(rng *rand.Rand) color.RGBA {
palette := []color.RGBA{
{R: 0x9c, G: 0xa3, B: 0xaf, A: 0xff},
{R: 0x6b, G: 0x72, B: 0x80, A: 0xff},
{R: 0xa1, G: 0x62, B: 0x07, A: 0xff},
{R: 0x0e, G: 0x74, B: 0x90, A: 0xff},
{R: 0xbe, G: 0x18, B: 0x5d, A: 0xff},
}
return palette[rng.Intn(len(palette))]
}