mirror of
https://github.com/go-gitea/gitea.git
synced 2026-10-03 04:31:52 +02:00
refactor: move go-chi/captcha into Gitea (#39529)
The `gitea.com/go-chi/captcha` package only exists for Gitea, so it moves into `modules/imagecaptcha`. - Reloading an expired challenge shows a new image instead of a broken one - Every answer is consumed on its first check - OpenID registration stops after a failed captcha --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
1 parent
e6ffbea888
commit
873efed5a6
17 files changed
+656
-59
No files matched your search
Generated
-5
@@ -19,11 +19,6 @@
|
||||
"path": "gitea.com/go-chi/cache/LICENSE",
|
||||
"licenseText": "Apache License\nVersion 2.0, January 2004\nhttp://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n\"License\" shall mean the terms and conditions for use, reproduction, and\ndistribution as defined by Sections 1 through 9 of this document.\n\n\"Licensor\" shall mean the copyright owner or entity authorized by the copyright\nowner that is granting the License.\n\n\"Legal Entity\" shall mean the union of the acting entity and all other entities\nthat control, are controlled by, or are under common control with that entity.\nFor the purposes of this definition, \"control\" means (i) the power, direct or\nindirect, to cause the direction or management of such entity, whether by\ncontract or otherwise, or (ii) ownership of fifty percent (50%) or more of the\noutstanding shares, or (iii) beneficial ownership of such entity.\n\n\"You\" (or \"Your\") shall mean an individual or Legal Entity exercising\npermissions granted by this License.\n\n\"Source\" form shall mean the preferred form for making modifications, including\nbut not limited to software source code, documentation source, and configuration\nfiles.\n\n\"Object\" form shall mean any form resulting from mechanical transformation or\ntranslation of a Source form, including but not limited to compiled object code,\ngenerated documentation, and conversions to other media types.\n\n\"Work\" shall mean the work of authorship, whether in Source or Object form, made\navailable under the License, as indicated by a copyright notice that is included\nin or attached to the work (an example is provided in the Appendix below).\n\n\"Derivative Works\" shall mean any work, whether in Source or Object form, that\nis based on (or derived from) the Work and for which the editorial revisions,\nannotations, elaborations, or other modifications represent, as a whole, an\noriginal work of authorship. For the purposes of this License, Derivative Works\nshall not include works that remain separable from, or merely link (or bind by\nname) to the interfaces of, the Work and Derivative Works thereof.\n\n\"Contribution\" shall mean any work of authorship, including the original version\nof the Work and any modifications or additions to that Work or Derivative Works\nthereof, that is intentionally submitted to Licensor for inclusion in the Work\nby the copyright owner or by an individual or Legal Entity authorized to submit\non behalf of the copyright owner. For the purposes of this definition,\n\"submitted\" means any form of electronic, verbal, or written communication sent\nto the Licensor or its representatives, including but not limited to\ncommunication on electronic mailing lists, source code control systems, and\nissue tracking systems that are managed by, or on behalf of, the Licensor for\nthe purpose of discussing and improving the Work, but excluding communication\nthat is conspicuously marked or otherwise designated in writing by the copyright\nowner as \"Not a Contribution.\"\n\n\"Contributor\" shall mean Licensor and any individual or Legal Entity on behalf\nof whom a Contribution has been received by Licensor and subsequently\nincorporated within the Work.\n\n2. Grant of Copyright License.\n\nSubject to the terms and conditions of this License, each Contributor hereby\ngrants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,\nirrevocable copyright license to reproduce, prepare Derivative Works of,\npublicly display, publicly perform, sublicense, and distribute the Work and such\nDerivative Works in Source or Object form.\n\n3. Grant of Patent License.\n\nSubject to the terms and conditions of this License, each Contributor hereby\ngrants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,\nirrevocable (except as stated in this section) patent license to make, have\nmade, use, offer to sell, sell, import, and otherwise transfer the Work, where\nsuch license applies only to those patent claims licensable by such Contributor\nthat are necessarily infringed by their Contribution(s) alone or by combination\nof their Contribution(s) with the Work to which such Contribution(s) was\nsubmitted. If You institute patent litigation against any entity (including a\ncross-claim or counterclaim in a lawsuit) alleging that the Work or a\nContribution incorporated within the Work constitutes direct or contributory\npatent infringement, then any patent licenses granted to You under this License\nfor that Work shall terminate as of the date such litigation is filed.\n\n4. Redistribution.\n\nYou may reproduce and distribute copies of the Work or Derivative Works thereof\nin any medium, with or without modifications, and in Source or Object form,\nprovided that You meet the following conditions:\n\nYou must give any other recipients of the Work or Derivative Works a copy of\nthis License; and\nYou must cause any modified files to carry prominent notices stating that YLine truncated
|
||||
},
|
||||
{
|
||||
"name": "gitea.com/go-chi/captcha",
|
||||
"path": "gitea.com/go-chi/captcha/LICENSE",
|
||||
"licenseText": "Apache License\nVersion 2.0, January 2004\nhttp://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n\"License\" shall mean the terms and conditions for use, reproduction, and\ndistribution as defined by Sections 1 through 9 of this document.\n\n\"Licensor\" shall mean the copyright owner or entity authorized by the copyright\nowner that is granting the License.\n\n\"Legal Entity\" shall mean the union of the acting entity and all other entities\nthat control, are controlled by, or are under common control with that entity.\nFor the purposes of this definition, \"control\" means (i) the power, direct or\nindirect, to cause the direction or management of such entity, whether by\ncontract or otherwise, or (ii) ownership of fifty percent (50%) or more of the\noutstanding shares, or (iii) beneficial ownership of such entity.\n\n\"You\" (or \"Your\") shall mean an individual or Legal Entity exercising\npermissions granted by this License.\n\n\"Source\" form shall mean the preferred form for making modifications, including\nbut not limited to software source code, documentation source, and configuration\nfiles.\n\n\"Object\" form shall mean any form resulting from mechanical transformation or\ntranslation of a Source form, including but not limited to compiled object code,\ngenerated documentation, and conversions to other media types.\n\n\"Work\" shall mean the work of authorship, whether in Source or Object form, made\navailable under the License, as indicated by a copyright notice that is included\nin or attached to the work (an example is provided in the Appendix below).\n\n\"Derivative Works\" shall mean any work, whether in Source or Object form, that\nis based on (or derived from) the Work and for which the editorial revisions,\nannotations, elaborations, or other modifications represent, as a whole, an\noriginal work of authorship. For the purposes of this License, Derivative Works\nshall not include works that remain separable from, or merely link (or bind by\nname) to the interfaces of, the Work and Derivative Works thereof.\n\n\"Contribution\" shall mean any work of authorship, including the original version\nof the Work and any modifications or additions to that Work or Derivative Works\nthereof, that is intentionally submitted to Licensor for inclusion in the Work\nby the copyright owner or by an individual or Legal Entity authorized to submit\non behalf of the copyright owner. For the purposes of this definition,\n\"submitted\" means any form of electronic, verbal, or written communication sent\nto the Licensor or its representatives, including but not limited to\ncommunication on electronic mailing lists, source code control systems, and\nissue tracking systems that are managed by, or on behalf of, the Licensor for\nthe purpose of discussing and improving the Work, but excluding communication\nthat is conspicuously marked or otherwise designated in writing by the copyright\nowner as \"Not a Contribution.\"\n\n\"Contributor\" shall mean Licensor and any individual or Legal Entity on behalf\nof whom a Contribution has been received by Licensor and subsequently\nincorporated within the Work.\n\n2. Grant of Copyright License.\n\nSubject to the terms and conditions of this License, each Contributor hereby\ngrants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,\nirrevocable copyright license to reproduce, prepare Derivative Works of,\npublicly display, publicly perform, sublicense, and distribute the Work and such\nDerivative Works in Source or Object form.\n\n3. Grant of Patent License.\n\nSubject to the terms and conditions of this License, each Contributor hereby\ngrants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,\nirrevocable (except as stated in this section) patent license to make, have\nmade, use, offer to sell, sell, import, and otherwise transfer the Work, where\nsuch license applies only to those patent claims licensable by such Contributor\nthat are necessarily infringed by their Contribution(s) alone or by combination\nof their Contribution(s) with the Work to which such Contribution(s) was\nsubmitted. If You institute patent litigation against any entity (including a\ncross-claim or counterclaim in a lawsuit) alleging that the Work or a\nContribution incorporated within the Work constitutes direct or contributory\npatent infringement, then any patent licenses granted to You under this License\nfor that Work shall terminate as of the date such litigation is filed.\n\n4. Redistribution.\n\nYou may reproduce and distribute copies of the Work or Derivative Works thereof\nin any medium, with or without modifications, and in Source or Object form,\nprovided that You meet the following conditions:\n\nYou must give any other recipients of the Work or Derivative Works a copy of\nthis License; and\nYou must cause any modified files to carry prominent notices stating that YLine truncated
|
||||
},
|
||||
{
|
||||
"name": "gitea.com/go-chi/session",
|
||||
"path": "gitea.com/go-chi/session/LICENSE",
|
||||
|
||||
@@ -7,7 +7,6 @@ toolchain go1.27.1
|
||||
require (
|
||||
connectrpc.com/connect v1.21.0
|
||||
gitea.com/go-chi/cache v0.2.1
|
||||
gitea.com/go-chi/captcha v0.0.0-20240315150714-fb487f629098
|
||||
gitea.com/go-chi/session v0.0.0-20260708011333-ebced8a7a2d6
|
||||
gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96
|
||||
gitea.com/lunny/levelqueue v0.4.2-0.20230414023320-3c0159fe0fe4
|
||||
|
||||
@@ -10,8 +10,6 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
gitea.com/go-chi/cache v0.2.1 h1:bfAPkvXlbcZxPCpcmDVCWoHgiBSBmZN/QosnZvEC0+g=
|
||||
gitea.com/go-chi/cache v0.2.1/go.mod h1:Qic0HZ8hOHW62ETGbonpwz8WYypj9NieU9659wFUJ8Q=
|
||||
gitea.com/go-chi/captcha v0.0.0-20240315150714-fb487f629098 h1:p2ki+WK0cIeNQuqjR98IP2KZQKRzJJiV7aTeMAFwaWo=
|
||||
gitea.com/go-chi/captcha v0.0.0-20240315150714-fb487f629098/go.mod h1:LjzIOHlRemuUyO7WR12fmm18VZIlCAaOt9L3yKw40pk=
|
||||
gitea.com/go-chi/session v0.0.0-20260708011333-ebced8a7a2d6 h1:YWzVGeC/8SZThrJS48ZmQYLkzssdeABxHPhbdnxPDIU=
|
||||
gitea.com/go-chi/session v0.0.0-20260708011333-ebced8a7a2d6/go.mod h1:KDvcfMUoXfATPHs2mbMoXFTXT45/FAFAS39waz9tPk0=
|
||||
gitea.com/lunny/dingtalk_webhook v0.0.0-20171025031554-e3534c89ef96 h1:+wWBi6Qfruqu7xJgjOIrKVQGiLUZdpKYCZewJ4clqhw=
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// Copyright 2011-2014 Dmitry Chestnykh. All rights reserved.
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package imagecaptcha
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
imageWidth = 240
|
||||
imageHeight = 80
|
||||
fontWidth = 11
|
||||
fontHeight = 18
|
||||
maxSkew = 0.7
|
||||
circleCount = 20
|
||||
)
|
||||
|
||||
type captchaImage struct {
|
||||
*image.Paletted
|
||||
rng *rand.Rand
|
||||
numWidth int
|
||||
numHeight int
|
||||
dotSize int
|
||||
}
|
||||
|
||||
func drawImage(rng *rand.Rand, code string) *image.Paletted {
|
||||
img := &captchaImage{rng: rng}
|
||||
img.initPalette()
|
||||
img.calculateSizes(len(code))
|
||||
border := imageHeight / 5
|
||||
maxX := imageWidth - (img.numWidth+img.dotSize)*len(code) - img.dotSize
|
||||
maxY := imageHeight - img.numHeight - img.dotSize*2
|
||||
x := img.randInt(border, maxX-border)
|
||||
y := img.randInt(border, maxY-border)
|
||||
for i := range code {
|
||||
img.drawDigit(code[i], x, y)
|
||||
x += img.numWidth + img.dotSize
|
||||
}
|
||||
img.strikeThrough()
|
||||
img.distort(img.randFloat(5, 10), img.randFloat(100, 200))
|
||||
img.fillWithCircles(circleCount, img.dotSize)
|
||||
return img.Paletted
|
||||
}
|
||||
|
||||
func (img *captchaImage) initPalette() {
|
||||
primary := primaryColors[img.rng.IntN(len(primaryColors))]
|
||||
palette := color.Palette{color.Transparent, primary}
|
||||
for range circleCount - 1 {
|
||||
palette = append(palette, img.randomBrightness(primary))
|
||||
}
|
||||
img.Paletted = image.NewPaletted(image.Rect(0, 0, imageWidth, imageHeight), palette)
|
||||
}
|
||||
|
||||
func (img *captchaImage) randomBrightness(c color.RGBA) color.RGBA {
|
||||
minChannel, maxChannel := min(c.R, c.G, c.B), max(c.R, c.G, c.B)
|
||||
shift := img.rng.IntN(math.MaxUint8-int(maxChannel)+1) - int(minChannel)
|
||||
return color.RGBA{R: uint8(int(c.R) + shift), G: uint8(int(c.G) + shift), B: uint8(int(c.B) + shift), A: c.A}
|
||||
}
|
||||
|
||||
func (img *captchaImage) randInt(from, to int) int {
|
||||
return img.rng.IntN(to+1-from) + from
|
||||
}
|
||||
|
||||
func (img *captchaImage) randFloat(from, to float64) float64 {
|
||||
return (to-from)*img.rng.Float64() + from
|
||||
}
|
||||
|
||||
func (img *captchaImage) calculateSizes(digitCount int) {
|
||||
border := imageHeight / 4
|
||||
width := float64(imageWidth - border*2)
|
||||
height := float64(imageHeight - border*2)
|
||||
glyphWidth := float64(fontWidth + 1)
|
||||
glyphHeight := float64(fontHeight)
|
||||
digitWidth := width / float64(digitCount)
|
||||
digitHeight := digitWidth * glyphHeight / glyphWidth
|
||||
if digitHeight > height {
|
||||
digitHeight = height
|
||||
digitWidth = glyphWidth / glyphHeight * digitHeight
|
||||
}
|
||||
img.dotSize = max(int(digitHeight/glyphHeight), 1)
|
||||
img.numWidth = int(digitWidth) - img.dotSize
|
||||
img.numHeight = int(digitHeight)
|
||||
}
|
||||
|
||||
func (img *captchaImage) drawHorizLine(fromX, toX, y int, colorIndex uint8) {
|
||||
for x := fromX; x <= toX; x++ {
|
||||
img.SetColorIndex(x, y, colorIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func (img *captchaImage) drawCircle(x, y, radius int, colorIndex uint8) {
|
||||
decision := 1 - radius
|
||||
offsetY := radius
|
||||
for offsetX := 0; offsetX <= offsetY; offsetX++ {
|
||||
img.drawHorizLine(x-offsetX, x+offsetX, y+offsetY, colorIndex)
|
||||
img.drawHorizLine(x-offsetX, x+offsetX, y-offsetY, colorIndex)
|
||||
img.drawHorizLine(x-offsetY, x+offsetY, y+offsetX, colorIndex)
|
||||
img.drawHorizLine(x-offsetY, x+offsetY, y-offsetX, colorIndex)
|
||||
if decision >= 0 {
|
||||
offsetY--
|
||||
decision -= 2 * offsetY
|
||||
}
|
||||
decision += 2*(offsetX+1) + 1
|
||||
}
|
||||
}
|
||||
|
||||
func (img *captchaImage) fillWithCircles(count, maxRadius int) {
|
||||
maxX, maxY := img.Bounds().Max.X, img.Bounds().Max.Y
|
||||
for range count {
|
||||
colorIndex := uint8(img.randInt(1, circleCount-1))
|
||||
radius := img.randInt(1, maxRadius)
|
||||
img.drawCircle(img.randInt(radius, maxX-radius), img.randInt(radius, maxY-radius), radius, colorIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func (img *captchaImage) strikeThrough() {
|
||||
maxX, maxY := img.Bounds().Max.X, img.Bounds().Max.Y
|
||||
y := img.randInt(maxY/3, maxY-maxY/3)
|
||||
amplitude := img.randFloat(5, 20)
|
||||
dx := 2.0 * math.Pi / img.randFloat(80, 180)
|
||||
offsetX := amplitude * math.Cos(float64(y)*dx)
|
||||
for x := range maxX {
|
||||
offsetY := amplitude * math.Sin(float64(x)*dx)
|
||||
for row := range img.dotSize {
|
||||
radius := img.randInt(0, img.dotSize)
|
||||
img.drawCircle(x+int(offsetX), y+int(offsetY)+(row*img.dotSize), radius/2, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (img *captchaImage) drawDigit(c byte, x, y int) {
|
||||
if c < '0' || c > '9' {
|
||||
return
|
||||
}
|
||||
digit := c - '0'
|
||||
skew := img.randFloat(-maxSkew, maxSkew)
|
||||
skewedX := float64(x)
|
||||
radius := img.dotSize / 2
|
||||
y += img.randInt(-radius, radius)
|
||||
fontRows := fontData()
|
||||
for row := range fontHeight {
|
||||
for col := range fontWidth {
|
||||
if fontRows[int(digit)*fontHeight+row][col] == '#' {
|
||||
img.drawCircle(x+col*img.dotSize, y+row*img.dotSize, radius, 1)
|
||||
}
|
||||
}
|
||||
skewedX += skew
|
||||
x = int(skewedX)
|
||||
}
|
||||
}
|
||||
|
||||
func (img *captchaImage) distort(amplitude, period float64) {
|
||||
width, height := img.Bounds().Max.X, img.Bounds().Max.Y
|
||||
distorted := image.NewPaletted(image.Rect(0, 0, width, height), img.Palette)
|
||||
dx := 2.0 * math.Pi / period
|
||||
offsetsX := make([]int, height)
|
||||
for y := range height {
|
||||
offsetsX[y] = int(amplitude * math.Sin(float64(y)*dx))
|
||||
}
|
||||
for x := range width {
|
||||
offsetY := int(amplitude * math.Cos(float64(x)*dx))
|
||||
for y := range height {
|
||||
distorted.SetColorIndex(x, y, img.ColorIndexAt(x+offsetsX[y], y+offsetY))
|
||||
}
|
||||
}
|
||||
img.Paletted = distorted
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package imagecaptcha
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"gitea.dev/modules/cache"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
const (
|
||||
cacheKeyPrefix = "captcha_"
|
||||
ttlSeconds = 600
|
||||
codeLength = 6
|
||||
)
|
||||
|
||||
var primaryColors = []color.RGBA{ // readable on both light and dark backgrounds
|
||||
{R: 234, G: 67, B: 53, A: 255},
|
||||
{R: 66, G: 133, B: 244, A: 255},
|
||||
{R: 52, G: 168, B: 83, A: 255},
|
||||
{R: 251, G: 188, B: 5, A: 255},
|
||||
{R: 171, G: 71, B: 188, A: 255},
|
||||
}
|
||||
|
||||
type pngBufferPool struct{ sync.Pool }
|
||||
|
||||
func (p *pngBufferPool) Get() *png.EncoderBuffer {
|
||||
buf, _ := p.Pool.Get().(*png.EncoderBuffer)
|
||||
return buf
|
||||
}
|
||||
|
||||
func (p *pngBufferPool) Put(buf *png.EncoderBuffer) {
|
||||
p.Pool.Put(buf)
|
||||
}
|
||||
|
||||
func randomCode() string {
|
||||
s := "000000" + strconv.Itoa(util.FastCryptoRandomInt(1000000))
|
||||
return s[len(s)-6:]
|
||||
}
|
||||
|
||||
var globalVars = sync.OnceValue(func() (ret struct {
|
||||
IdLength int
|
||||
IdRegexp *regexp.Regexp
|
||||
NoiseKey []byte
|
||||
},
|
||||
) {
|
||||
ret.IdLength = 40
|
||||
ret.IdRegexp = regexp.MustCompile(`^[0-9a-f]{40}$`)
|
||||
ret.NoiseKey = util.FastCryptoRandomBytes(32)
|
||||
return
|
||||
})
|
||||
|
||||
// noiseRand makes refetches of an image identical, so averaging them does not remove the noise
|
||||
func noiseRand(id, code string) *rand.Rand {
|
||||
mac := hmac.New(sha256.New, globalVars().NoiseKey)
|
||||
_, _ = mac.Write([]byte(id + "\x00" + code))
|
||||
return util.FastCryptoRand([32]byte(mac.Sum(nil)))
|
||||
}
|
||||
|
||||
func CreateNew() (string, error) {
|
||||
id := util.FastCryptoRandomHex(globalVars().IdLength)
|
||||
_, err := PrepareCode(id, true)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func PrepareCode(id string, generateNew bool) (code string, err error) {
|
||||
if !globalVars().IdRegexp.MatchString(id) {
|
||||
return "", nil
|
||||
}
|
||||
cacheKey := cacheKeyPrefix + id
|
||||
if generateNew {
|
||||
code = randomCode()
|
||||
if err = cache.GetCache().Put(cacheKey, code, ttlSeconds); err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
code, _ = cache.GetCache().Get(cacheKey)
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
func Verify(id, answer string) bool {
|
||||
if !globalVars().IdRegexp.MatchString(id) {
|
||||
return false
|
||||
}
|
||||
key := cacheKeyPrefix + id
|
||||
code, ok := cache.GetCache().Get(key)
|
||||
_ = cache.GetCache().Delete(key)
|
||||
return ok && answer == code
|
||||
}
|
||||
|
||||
func ServeImage(resp http.ResponseWriter, req *http.Request) {
|
||||
urlQuery := req.URL.Query()
|
||||
id, reload := urlQuery.Get("id"), urlQuery.Get("reload") != ""
|
||||
code, err := PrepareCode(id, reload)
|
||||
if err != nil {
|
||||
log.Error("Failed to prepare captcha code for id %s: %v", id, err)
|
||||
http.Error(resp, "Failed to prepare captcha code", http.StatusInternalServerError)
|
||||
return
|
||||
} else if code == "" {
|
||||
http.NotFound(resp, req)
|
||||
return
|
||||
}
|
||||
|
||||
resp.Header().Set("Cache-Control", "no-store")
|
||||
resp.Header().Set("Content-Type", "image/png")
|
||||
if req.Method == http.MethodGet {
|
||||
pngEncoder := png.Encoder{BufferPool: &pngBufferPool{}}
|
||||
_ = pngEncoder.Encode(resp, drawImage(noiseRand(id, code), code))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package imagecaptcha
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/cache"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestImageCaptcha(t *testing.T) {
|
||||
require.NoError(t, cache.Init())
|
||||
createWithAnswer := func() (string, string) {
|
||||
id, err := CreateNew()
|
||||
require.NoError(t, err)
|
||||
code, ok := cache.GetCache().Get(cacheKeyPrefix + id)
|
||||
require.True(t, ok)
|
||||
return id, code
|
||||
}
|
||||
renderImage := func(id string, refresh bool) *httptest.ResponseRecorder {
|
||||
resp := httptest.NewRecorder()
|
||||
reqLink := "/captcha?id=" + id
|
||||
if refresh {
|
||||
reqLink += "&reload=any"
|
||||
}
|
||||
ServeImage(resp, httptest.NewRequest(http.MethodGet, reqLink, nil))
|
||||
return resp
|
||||
}
|
||||
id, answer := createWithAnswer()
|
||||
assert.Len(t, answer, codeLength)
|
||||
assert.True(t, Verify(id, answer))
|
||||
assert.False(t, Verify(id, answer))
|
||||
|
||||
id, answer = createWithAnswer()
|
||||
assert.False(t, Verify(id, "wrong"))
|
||||
assert.False(t, Verify(id, answer))
|
||||
assert.False(t, Verify("", ""))
|
||||
assert.False(t, Verify("unknown", answer))
|
||||
|
||||
resp := renderImage("unknown", true)
|
||||
assert.Equal(t, http.StatusNotFound, resp.Code)
|
||||
_, exists := cache.GetCache().Get(cacheKeyPrefix + "unknown")
|
||||
assert.False(t, exists)
|
||||
|
||||
id, _ = createWithAnswer()
|
||||
first := renderImage(id, false)
|
||||
decoded, err := png.Decode(bytes.NewReader(first.Body.Bytes()))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, image.Rect(0, 0, imageWidth, imageHeight), decoded.Bounds())
|
||||
second := renderImage(id, false)
|
||||
assert.Equal(t, first.Body.Bytes(), second.Body.Bytes())
|
||||
|
||||
require.NoError(t, cache.GetCache().Delete(cacheKeyPrefix+id))
|
||||
_ = renderImage(id, true)
|
||||
_, exists = cache.GetCache().Get(cacheKeyPrefix + id)
|
||||
assert.True(t, exists)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package imagecaptcha
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var fontData = sync.OnceValue(func() []string {
|
||||
return strings.Fields(`
|
||||
...#####...
|
||||
..#######..
|
||||
.###...###.
|
||||
.##.....##.
|
||||
###.....##.
|
||||
##.......##
|
||||
##.......##
|
||||
##.......##
|
||||
##.......##
|
||||
##.......##
|
||||
##.......##
|
||||
##.......##
|
||||
##.......##
|
||||
##......###
|
||||
.##.....##.
|
||||
.###...###.
|
||||
..#######..
|
||||
...#####...
|
||||
|
||||
.....##....
|
||||
....###....
|
||||
...####....
|
||||
..#####....
|
||||
..##.##....
|
||||
..#..##....
|
||||
.....##....
|
||||
.....##....
|
||||
.....##....
|
||||
.....##....
|
||||
.....##....
|
||||
.....##....
|
||||
.....##....
|
||||
.....##....
|
||||
.....##....
|
||||
.....##....
|
||||
.##########
|
||||
.##########
|
||||
|
||||
...####....
|
||||
.########..
|
||||
###....###.
|
||||
.#......##.
|
||||
........##.
|
||||
........##.
|
||||
........##.
|
||||
.......##..
|
||||
.......##..
|
||||
......##...
|
||||
.....##....
|
||||
....###....
|
||||
...###.....
|
||||
..###......
|
||||
.###.......
|
||||
.##........
|
||||
###########
|
||||
###########
|
||||
|
||||
..######...
|
||||
#########..
|
||||
##.....###.
|
||||
........##.
|
||||
........##.
|
||||
........##.
|
||||
......###..
|
||||
..#####....
|
||||
..#######..
|
||||
.......###.
|
||||
........###
|
||||
.........##
|
||||
.........##
|
||||
.........##
|
||||
........###
|
||||
#......###.
|
||||
#########..
|
||||
.#######...
|
||||
|
||||
.......##..
|
||||
......###..
|
||||
.....####..
|
||||
.....#.##..
|
||||
....##.##..
|
||||
...##..##..
|
||||
...##..##..
|
||||
..##...##..
|
||||
.##....##..
|
||||
.##....##..
|
||||
##.....##..
|
||||
#......##..
|
||||
###########
|
||||
###########
|
||||
.......##..
|
||||
.......##..
|
||||
.......##..
|
||||
.......##..
|
||||
|
||||
.#########.
|
||||
.#########.
|
||||
.##........
|
||||
.##........
|
||||
.##........
|
||||
.##........
|
||||
.#######...
|
||||
.########..
|
||||
.......###.
|
||||
........###
|
||||
.........##
|
||||
.........##
|
||||
.........##
|
||||
.........##
|
||||
........###
|
||||
##.....###.
|
||||
#########..
|
||||
..######...
|
||||
|
||||
.....#####.
|
||||
...#######.
|
||||
..###......
|
||||
.##........
|
||||
.##........
|
||||
.#.........
|
||||
##..####...
|
||||
##.#######.
|
||||
####....##.
|
||||
###.....###
|
||||
##.......##
|
||||
##.......##
|
||||
##.......##
|
||||
##.......##
|
||||
.##.....###
|
||||
.###...###.
|
||||
..#######..
|
||||
...#####...
|
||||
|
||||
###########
|
||||
###########
|
||||
###......##
|
||||
##......##.
|
||||
........##.
|
||||
.......###.
|
||||
.......##..
|
||||
.......##..
|
||||
......##...
|
||||
......##...
|
||||
.....###...
|
||||
.....##....
|
||||
....###....
|
||||
....##.....
|
||||
....##.....
|
||||
...###.....
|
||||
...##......
|
||||
..###......
|
||||
|
||||
...#####...
|
||||
..########.
|
||||
.###....###
|
||||
.##......##
|
||||
.##......##
|
||||
.##......##
|
||||
..##....##.
|
||||
..#######..
|
||||
....####...
|
||||
..###.###..
|
||||
.###...###.
|
||||
###.....###
|
||||
##.......##
|
||||
##.......##
|
||||
##.......##
|
||||
###.....##.
|
||||
.#########.
|
||||
...#####...
|
||||
|
||||
...#####...
|
||||
.########..
|
||||
.##....###.
|
||||
##......##.
|
||||
##.......##
|
||||
##.......##
|
||||
##.......##
|
||||
##......###
|
||||
.##....####
|
||||
.#######.##
|
||||
...####..##
|
||||
.........##
|
||||
........##.
|
||||
........##.
|
||||
.......###.
|
||||
......###..
|
||||
.#######...
|
||||
.#####.....
|
||||
`)
|
||||
})
|
||||
+12
-1
@@ -111,7 +111,7 @@ func FastCryptoRandomBytes(length int) []byte {
|
||||
// ChaCha8 is about 20x times faster than system's crypto/rand.
|
||||
// It is suitable for UUIDs, session IDs, etc
|
||||
pool := chaCha8RandPool()
|
||||
chaCha8Rand := pool.Get().(*rand2.ChaCha8) //nolint:forcetypeassert // the pool's New only ever makes *rand2.ChaCha8
|
||||
chaCha8Rand, _ := pool.Get().(*rand2.ChaCha8)
|
||||
defer pool.Put(chaCha8Rand)
|
||||
buf := make([]byte, length)
|
||||
_, _ = chaCha8Rand.Read(buf)
|
||||
@@ -123,6 +123,17 @@ func FastCryptoRandomHex(length int) string {
|
||||
return hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
func FastCryptoRandomInt[T int | int64](n T) T {
|
||||
pool := chaCha8RandPool()
|
||||
chaCha8Rand, _ := pool.Get().(*rand2.ChaCha8)
|
||||
defer pool.Put(chaCha8Rand)
|
||||
return rand2.New(chaCha8Rand).N(n)
|
||||
}
|
||||
|
||||
func FastCryptoRand(seed [32]byte) *rand2.Rand {
|
||||
return rand2.New(rand2.NewChaCha8(seed))
|
||||
}
|
||||
|
||||
// ToLowerASCII returns s with all ASCII letters mapped to their lower case.
|
||||
func ToLowerASCII(s string) string {
|
||||
b := []byte(s)
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/auth/password"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/imagecaptcha"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/optional"
|
||||
"gitea.dev/modules/session"
|
||||
@@ -70,7 +71,7 @@ func prepareCommonAuthPageData(ctx *context.Context, opt CommonAuthOptions) {
|
||||
ctx.Data["McaptchaURL"] = strings.TrimSuffix(setting.Service.McaptchaURL, "/")
|
||||
ctx.Data["CfTurnstileSitekey"] = setting.Service.CfTurnstileSitekey
|
||||
if setting.Service.CaptchaType == setting.ImageCaptcha {
|
||||
ctx.Data["Captcha"] = context.GetImageCaptcha()
|
||||
ctx.Data["CreateImageCaptcha"] = imagecaptcha.CreateNew
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -298,8 +299,7 @@ func SignInPost(ctx *context.Context) {
|
||||
form := web.GetForm[*forms.SignInForm](ctx)
|
||||
|
||||
if setting.Service.EnableCaptcha && setting.Service.RequireCaptchaForLogin {
|
||||
context.VerifyCaptcha(ctx, tplSignIn, form)
|
||||
if ctx.Written() {
|
||||
if !context.VerifyCaptcha(ctx, tplSignIn, form) {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -553,8 +553,7 @@ func SignUpPost(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
context.VerifyCaptcha(ctx, tplSignUp, form)
|
||||
if ctx.Written() {
|
||||
if !context.VerifyCaptcha(ctx, tplSignUp, form) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -17,8 +17,10 @@ import (
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/modules/util"
|
||||
"gitea.dev/modules/web"
|
||||
"gitea.dev/services/auth/source/oauth2"
|
||||
"gitea.dev/services/contexttest"
|
||||
"gitea.dev/services/forms"
|
||||
|
||||
"github.com/markbates/goth"
|
||||
"github.com/markbates/goth/gothic"
|
||||
@@ -199,3 +201,18 @@ func TestOpenIDRequireTwoFactor(t *testing.T) {
|
||||
openIDRequireTwoFactor(ctx, user2, false, "https://example.com/id")
|
||||
assert.False(t, ctx.Written())
|
||||
}
|
||||
|
||||
func TestRegisterOpenIDPostRejectsWrongCaptcha(t *testing.T) {
|
||||
require.NoError(t, unittest.PrepareTestDatabase())
|
||||
defer test.MockVariableValue(&setting.Service.EnableCaptcha, true)()
|
||||
defer test.MockVariableValue(&setting.Service.CaptchaType, setting.ImageCaptcha)()
|
||||
sess := session.NewMockMemStore("dummy-sid-openid-register")
|
||||
require.NoError(t, sess.Set("openid_verified_uri", "https://example.com/openid"))
|
||||
|
||||
ctx, _ := contexttest.MockContext(t, "POST /user/openid/register", contexttest.MockContextOption{SessionStore: sess})
|
||||
contexttest.MockRequestPostForm(ctx.Req, url.Values{"captcha_id": {"unknown"}, "captcha": {"000000"}})
|
||||
web.SetForm(ctx, &forms.SignUpOpenIDForm{UserName: "openid-captcha-user", Email: "openid-captcha-user@example.com"})
|
||||
RegisterOpenIDPost(ctx)
|
||||
assert.Equal(t, true, ctx.Data["Err_Captcha"])
|
||||
unittest.AssertNotExistsBean(t, &user_model.User{LowerName: "openid-captcha-user"})
|
||||
}
|
||||
@@ -38,6 +38,7 @@ func prepareLinkAccountPageData(ctx *context.Context) {
|
||||
ctx.Data["ShowRegistrationButton"] = false
|
||||
ctx.Data["DisableRegistration"] = setting.Service.DisableRegistration
|
||||
|
||||
// FIXME: this logic is not right: captcha is enabled, but LinkAccountPostSignIn never checks for captcha
|
||||
prepareCommonAuthPageData(ctx, CommonAuthOptions{
|
||||
EnableCaptcha: setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha,
|
||||
})
|
||||
@@ -198,8 +199,7 @@ func LinkAccountPostRegister(ctx *context.Context) {
|
||||
}
|
||||
|
||||
if setting.Service.EnableCaptcha && setting.Service.RequireExternalRegistrationCaptcha {
|
||||
context.VerifyCaptcha(ctx, tplLinkAccount, form)
|
||||
if ctx.Written() {
|
||||
if !context.VerifyCaptcha(ctx, tplLinkAccount, form) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,7 +378,9 @@ func RegisterOpenIDPost(ctx *context.Context) {
|
||||
ctx.ServerError("", err)
|
||||
return
|
||||
}
|
||||
context.VerifyCaptcha(ctx, tplSignUpOID, form)
|
||||
if !context.VerifyCaptcha(ctx, tplSignUpOID, form) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
length := max(setting.MinPasswordLength, 256)
|
||||
|
||||
+3
-4
@@ -13,6 +13,7 @@ import (
|
||||
"gitea.dev/models/unit"
|
||||
"gitea.dev/modules/git"
|
||||
"gitea.dev/modules/graceful"
|
||||
"gitea.dev/modules/imagecaptcha"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/metrics"
|
||||
"gitea.dev/modules/public"
|
||||
@@ -50,7 +51,6 @@ import (
|
||||
|
||||
_ "gitea.dev/modules/session" // to register all internal adapters
|
||||
|
||||
"gitea.com/go-chi/captcha"
|
||||
chi_middleware "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/klauspost/compress/gzhttp"
|
||||
@@ -293,9 +293,8 @@ func Routes() *web.Router {
|
||||
mid = append(mid, wrapper)
|
||||
}
|
||||
|
||||
if setting.Service.EnableCaptcha {
|
||||
// The captcha http.Handler should only fire on /captcha/* so we can just mount this on that url
|
||||
routes.Methods("GET,HEAD", "/captcha/*", append(mid, captcha.Captchaer(context.GetImageCaptcha()))...)
|
||||
if setting.Service.EnableCaptcha && setting.Service.CaptchaType == setting.ImageCaptcha {
|
||||
routes.Methods("GET,HEAD", `/captcha`, append(mid, imagecaptcha.ServeImage)...)
|
||||
}
|
||||
|
||||
if setting.Metrics.Enabled {
|
||||
|
||||
+10
-36
@@ -5,65 +5,37 @@ package context
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"sync"
|
||||
|
||||
"gitea.dev/modules/cache"
|
||||
"gitea.dev/modules/hcaptcha"
|
||||
"gitea.dev/modules/imagecaptcha"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/mcaptcha"
|
||||
"gitea.dev/modules/recaptcha"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/templates"
|
||||
"gitea.dev/modules/turnstile"
|
||||
|
||||
"gitea.com/go-chi/captcha"
|
||||
)
|
||||
|
||||
var (
|
||||
imageCaptchaOnce sync.Once
|
||||
cpt *captcha.Captcha
|
||||
)
|
||||
|
||||
// GetImageCaptcha returns global image captcha
|
||||
func GetImageCaptcha() *captcha.Captcha {
|
||||
imageCaptchaOnce.Do(func() {
|
||||
cpt = captcha.NewCaptcha(captcha.Options{
|
||||
SubURL: setting.AppSubURL,
|
||||
// Use a color palette with high contrast colors suitable for both light and dark modes
|
||||
// These colors provide good visibility and readability in both themes
|
||||
ColorPalette: color.Palette{
|
||||
color.RGBA{R: 234, G: 67, B: 53, A: 255}, // Bright red
|
||||
color.RGBA{R: 66, G: 133, B: 244, A: 255}, // Medium blue
|
||||
color.RGBA{R: 52, G: 168, B: 83, A: 255}, // Green
|
||||
color.RGBA{R: 251, G: 188, B: 5, A: 255}, // Yellow/gold
|
||||
color.RGBA{R: 171, G: 71, B: 188, A: 255}, // Purple
|
||||
},
|
||||
})
|
||||
cpt.Store = cache.GetCache().ChiCache()
|
||||
})
|
||||
return cpt
|
||||
}
|
||||
|
||||
const (
|
||||
imageCaptchaIDField = "captcha_id"
|
||||
imageCaptchaAnswerField = "captcha"
|
||||
gRecaptchaResponseField = "g-recaptcha-response"
|
||||
hCaptchaResponseField = "h-captcha-response"
|
||||
mCaptchaResponseField = "mcaptcha__token" // this form key is hard-coded in the mcaptcha frontend library
|
||||
cfTurnstileResponseField = "cf-turnstile-response"
|
||||
)
|
||||
|
||||
// VerifyCaptcha verifies Captcha data
|
||||
// No-op if captchas are not enabled
|
||||
func VerifyCaptcha(ctx *Context, tpl templates.TplName, form any) {
|
||||
// VerifyCaptcha returns whether the captcha is solved or disabled, otherwise it renders tpl with an error
|
||||
func VerifyCaptcha(ctx *Context, tpl templates.TplName, form any) bool {
|
||||
if !setting.Service.EnableCaptcha {
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
var valid bool
|
||||
var err error
|
||||
switch setting.Service.CaptchaType {
|
||||
case setting.ImageCaptcha:
|
||||
valid = GetImageCaptcha().VerifyReq(ctx.Req)
|
||||
valid = imagecaptcha.Verify(ctx.FormString(imageCaptchaIDField), ctx.FormString(imageCaptchaAnswerField))
|
||||
case setting.ReCaptcha:
|
||||
valid, err = recaptcha.Verify(ctx, ctx.Req.Form.Get(gRecaptchaResponseField))
|
||||
case setting.HCaptcha:
|
||||
@@ -74,7 +46,7 @@ func VerifyCaptcha(ctx *Context, tpl templates.TplName, form any) {
|
||||
valid, err = turnstile.Verify(ctx, ctx.Req.Form.Get(cfTurnstileResponseField))
|
||||
default:
|
||||
ctx.ServerError("Unknown Captcha Type", fmt.Errorf("unknown Captcha Type: %s", setting.Service.CaptchaType))
|
||||
return
|
||||
return false
|
||||
}
|
||||
if err != nil {
|
||||
log.Debug("Captcha Verify failed: %v", err)
|
||||
@@ -83,5 +55,7 @@ func VerifyCaptcha(ctx *Context, tpl templates.TplName, form any) {
|
||||
if !valid {
|
||||
ctx.Data["Err_Captcha"] = true
|
||||
ctx.RenderWithErrDeprecated(ctx.Tr("form.captcha_incorrect"), tpl, form)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
{{if .EnableCaptcha}}{{if eq .CaptchaType "image"}}
|
||||
{{$captchaID := call .CreateImageCaptcha}}
|
||||
<div class="inline field tw-text-center">
|
||||
{{.Captcha.CreateHTML}}
|
||||
<input type="hidden" name="captcha_id" value="{{$captchaID}}">
|
||||
<img class="tw-cursor-pointer" src="{{AppSubUrl}}/captcha?id={{$captchaID}}" width="240" height="80" alt="{{ctx.Locale.Tr "captcha"}}" data-global-click="onImageCaptchaReload">
|
||||
</div>
|
||||
<div class="required field {{if .Err_Captcha}}error{{end}}">
|
||||
<label for="captcha">{{ctx.Locale.Tr "captcha"}}</label>
|
||||
<input id="captcha" name="captcha" value="{{.captcha}}" autocomplete="off">
|
||||
<input id="captcha" name="captcha" autocomplete="off">
|
||||
</div>
|
||||
{{else if eq .CaptchaType "recaptcha"}}
|
||||
<div class="inline field tw-text-center required">
|
||||
|
||||
@@ -12,9 +12,11 @@ import (
|
||||
"gitea.dev/models/db"
|
||||
"gitea.dev/models/unittest"
|
||||
user_model "gitea.dev/models/user"
|
||||
"gitea.dev/modules/cache"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
"gitea.dev/modules/translation"
|
||||
"gitea.dev/routers"
|
||||
"gitea.dev/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -35,6 +37,35 @@ func TestSignup(t *testing.T) {
|
||||
// should be able to view new user's page
|
||||
req = NewRequest(t, "GET", "/exampleUser")
|
||||
MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
t.Run("ImageCaptcha", func(t *testing.T) {
|
||||
defer test.MockVariableValue(&setting.Service.EnableCaptcha, true)()
|
||||
defer test.MockVariableValue(&setting.Service.CaptchaType, setting.ImageCaptcha)()
|
||||
defer test.MockVariableValue(&testWebRoutes, routers.NormalRoutes())()
|
||||
|
||||
resp := MakeRequest(t, NewRequest(t, "GET", "/user/sign_up"), http.StatusOK)
|
||||
captchaID := NewHTMLParser(t, resp.Body).GetInputValueByName("captcha_id")
|
||||
resp = MakeRequest(t, NewRequest(t, "GET", "/captcha?id="+captchaID), http.StatusOK)
|
||||
assert.Equal(t, "image/png", resp.Header().Get("Content-Type"))
|
||||
assert.Equal(t, "no-store", resp.Header().Get("Cache-Control"))
|
||||
|
||||
values := map[string]string{
|
||||
"user_name": "captchaUser",
|
||||
"email": "captchaUser@example.com",
|
||||
"password": "examplePassword!1",
|
||||
"retype": "examplePassword!1",
|
||||
"captcha_id": captchaID,
|
||||
"captcha": "wrong",
|
||||
}
|
||||
resp = MakeRequest(t, NewRequestWithValues(t, "POST", "/user/sign_up", values), http.StatusOK)
|
||||
htmlDoc := NewHTMLParser(t, resp.Body)
|
||||
assert.Equal(t, translation.NewLocale("en-US").TrString("form.captcha_incorrect"), strings.TrimSpace(htmlDoc.Find(".ui.message").Text()))
|
||||
|
||||
values["captcha_id"] = htmlDoc.GetInputValueByName("captcha_id")
|
||||
captchaCode, _ := cache.GetCache().Get("captcha_" + values["captcha_id"])
|
||||
values["captcha"] = captchaCode
|
||||
MakeRequest(t, NewRequestWithValues(t, "POST", "/user/sign_up", values), http.StatusSeeOther)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSignupAsRestricted(t *testing.T) {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import {isDarkTheme} from '../utils.ts';
|
||||
import {registerGlobalEventFunc} from '../modules/observer.ts';
|
||||
|
||||
export async function initCaptcha() {
|
||||
registerGlobalEventFunc('click', 'onImageCaptchaReload', (img: HTMLImageElement) => {
|
||||
const url = new URL(img.src);
|
||||
url.searchParams.set('reload', String(Date.now()));
|
||||
img.src = url.href;
|
||||
});
|
||||
|
||||
const captchaEl = document.querySelector('#captcha');
|
||||
if (!captchaEl) return;
|
||||
|
||||
|
||||
Reference in new issue
Block a user