package captcha import ( "context" "crypto/rand" "encoding/base64" "errors" "fmt" "html" "math/big" "strings" "time" "github.com/redis/go-redis/v9" ) var ( ErrDependencyUnavailable = errors.New("captcha dependency unavailable") ErrInvalid = errors.New("captcha invalid") ) type Item struct { CaptchaID string `json:"captcha_id"` Image string `json:"image"` ExpiresIn int64 `json:"expires_in"` } func Generate(ctx context.Context, redisClient *redis.Client, namespace string, ttl time.Duration) (*Item, error) { if redisClient == nil { return nil, ErrDependencyUnavailable } captchaID, err := randomToken(16) if err != nil { return nil, err } code, err := randomCaptchaCode(4) if err != nil { return nil, err } if err := redisClient.Set(ctx, key(namespace, captchaID), strings.ToUpper(code), ttl).Err(); err != nil { return nil, err } return &Item{ CaptchaID: captchaID, Image: imageDataURL(code), ExpiresIn: int64(ttl.Seconds()), }, nil } func Verify(ctx context.Context, redisClient *redis.Client, namespace string, captchaID string, captchaCode string) error { if redisClient == nil { return ErrDependencyUnavailable } captchaID = strings.TrimSpace(captchaID) captchaCode = strings.TrimSpace(captchaCode) if captchaID == "" || captchaCode == "" { return ErrInvalid } redisKey := key(namespace, captchaID) stored, err := redisClient.Get(ctx, redisKey).Result() if errors.Is(err, redis.Nil) { return ErrInvalid } if err != nil { return err } _ = redisClient.Del(ctx, redisKey).Err() if strings.ToUpper(captchaCode) != stored { return ErrInvalid } return nil } func key(namespace string, id string) string { namespace = strings.Trim(strings.ToLower(strings.TrimSpace(namespace)), ":") if namespace == "" { namespace = "default" } return "captcha:" + namespace + ":" + id } func randomToken(length int) (string, error) { buf := make([]byte, length) if _, err := rand.Read(buf); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(buf), nil } func randomCaptchaCode(length int) (string, error) { const alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ" result := make([]byte, length) for i := range result { n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet)))) if err != nil { return "", err } result[i] = alphabet[n.Int64()] } return string(result), nil } func imageDataURL(code string) string { safeCode := html.EscapeString(strings.ToUpper(code)) svg := fmt.Sprintf(` %s `, safeCode) return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(svg)) }