diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml new file mode 100644 index 0000000..8587af3 --- /dev/null +++ b/.forgejo/workflows/test.yml @@ -0,0 +1,23 @@ +name: test +on: + pull_request: + push: + branches: + - main + +jobs: + test: + runs-on: docker-global-bookworm + container: + image: 'code.forgejo.org/oci/node:20-bookworm' + steps: + - uses: https://code.forgejo.org/actions/checkout@v4 + - uses: https://code.forgejo.org/actions/setup-go@v5 + with: + go-version-file: "go.mod" + - name: golangci-lint + uses: https://github.com/golangci/golangci-lint-action@v6 + with: + version: v1.60.3 # renovate: datasource=go depName=golangci-lint packageName=github.com/golangci/golangci-lint/cmd/golangci-lint + - name: test + run: go test -v ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea1cde5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Generated test captchas +capgen/*.png +capgen/*.wav + +# Programs +capgen/capgen +cangensounds/cangensounds +capexample/capexample diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..25287ce --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,27 @@ +linters: + enable-all: false + disable-all: true + fast: false + enable: + - bidichk + - dupl + - errcheck + - forbidigo + - gocritic + - gofmt + - gofumpt + - gosimple + - govet + - ineffassign + - nakedret + - nolintlint + - revive + - staticcheck + - stylecheck + - tenv + - testifylint + - typecheck + - unconvert + - unused + - unparam + - wastedassign diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0ad73ae --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2011-2014 Dmitry Chestnykh + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..72146b8 --- /dev/null +++ b/README.md @@ -0,0 +1,220 @@ +Package captcha +===================== + +This is a fork of https://github.com/dchest/captcha + +**:warning: Warning: this captcha can be broken by advanced OCR captcha breaking algorithms.** + + import "code.forgejo.org/go-chi/captcha" + +Package captcha implements generation and verification of image CAPTCHAs. + +A captcha solution is the sequence of digits 0-9 with the defined length. +There is one captcha representations: image. + +An image representation is a PNG-encoded image with the solution printed on +it in such a way that makes it hard for computers to solve it using OCR. + +This package doesn't require external files or libraries to generate captcha +representations; it is self-contained. + +To make captchas one-time, the package includes a memory storage that stores +captcha ids, their solutions, and expiration time. Used captchas are removed +from the store immediately after calling Verify or VerifyString, while +unused captchas (user loaded a page with captcha, but didn't submit the +form) are collected automatically after the predefined expiration time. +Developers can also provide custom store (for example, which saves captcha +ids and solutions in database) by implementing Store interface and +registering the object with SetCustomStore. + +Captchas are created by calling New, which returns the captcha id. Their +representations, though, are created on-the-fly by calling WriteImage function. +Created representations are not stored anywhere, but subsequent calls to these +functions with the same id will write the same captcha solution. Reload function +will create a new different solution for the provided captcha, allowing users to +"reload" captcha if they can't solve the displayed one without reloading the +whole page. Verify and VerifyString are used to verify that the given solution +is the right one for the given captcha id. + +Server provides an http.Handler which can serve image representations of +captchas automatically from the URL. It can also be used to reload captchas. +Refer to Server function documentation for details, or take a look at the +example in "capexample" subdirectory. + + +Examples +-------- + +![Image](https://github.com/dchest/captcha/raw/master/capgen/example.png) + +Constants +--------- + +``` go +const ( + // Default number of digits in captcha solution. + DefaultLen = 6 + // The number of captchas created that triggers garbage collection used + // by default store. + CollectNum = 100 + // Expiration time of captchas used by default store. + Expiration = 10 * time.Minute +) +``` + +``` go +const ( + // Standard width and height of a captcha image. + StdWidth = 240 + StdHeight = 80 +) +``` + + +Variables +--------- + +``` go +var ( + ErrNotFound = errors.New("captcha: id not found") +) +``` + + + +Functions +--------- + +### func New + + func New() string + +New creates a new captcha with the standard length, saves it in the internal +storage and returns its id. + +### func NewLen + + func NewLen(length int) (id string) + +NewLen is just like New, but accepts length of a captcha solution as the +argument. + +### func RandomDigits + + func RandomDigits(length int) (b []byte) + +RandomDigits returns a byte slice of the given length containing +pseudorandom numbers in range 0-9. The slice can be used as a captcha +solution. + +### func Reload + + func Reload(id string) bool + +Reload generates and remembers new digits for the given captcha id. This +function returns false if there is no captcha with the given id. + +After calling this function, the image presented to a user must be refreshed to +show the new captcha representation (WriteImage will write the new one). + +### func Server + + func Server(imgWidth, imgHeight int) http.Handler + +Server returns a handler that serves HTTP requests with image or +representations of captchas. Image dimensions are accepted as +arguments. The server decides which captcha to serve based on the last URL +path component: file name part must contain a captcha id, file extension — +its format (PNG or WAV). + +For example, for file name "LBm5vMjHDtdUfaWYXiQX.png" it serves an image captcha +with id "LBm5vMjHDtdUfaWYXiQX". + +To reload captcha (get a different solution for the same captcha id), append +"?reload=x" to URL, where x may be anything (for example, current time or a +random number to make browsers refetch an image instead of loading it from +cache). + +### func SetCustomStore + + func SetCustomStore(s Store) + +SetCustomStore sets custom storage for captchas, replacing the default +memory store. This function must be called before generating any captchas. + +### func Verify + + func Verify(id string, digits []byte) bool + +Verify returns true if the given digits are the ones that were used to +create the given captcha id. + +The function deletes the captcha with the given id from the internal +storage, so that the same captcha can't be verified anymore. + +### func VerifyString + + func VerifyString(id string, digits string) bool + +VerifyString is like Verify, but accepts a string of digits. It removes +spaces and commas from the string, but any other characters, apart from +digits and listed above, will cause the function to return false. + +### func WriteImage + + func WriteImage(w io.Writer, id string, width, height int) error + +WriteImage writes PNG-encoded image representation of the captcha with the +given id. The image will have the given width and height. + + +Types +----- + +``` go +type Image struct { + *image.Paletted + // contains unexported fields +} +``` + + +### func NewImage + + func NewImage(id string, digits []byte, width, height int) *Image + +NewImage returns a new captcha image of the given width and height with the +given digits, where each digit must be in range 0-9. + +### func (*Image) WriteTo + + func (m *Image) WriteTo(w io.Writer) (int64, error) + +WriteTo writes captcha image in PNG format into the given writer. + +``` go +type Store interface { + // Set sets the digits for the captcha id. + Set(id string, digits []byte) + + // Get returns stored digits for the captcha id. Clear indicates + // whether the captcha must be deleted from the store. + Get(id string, clear bool) (digits []byte) +} +``` + +An object implementing Store interface can be registered with SetCustomStore +function to handle storage and retrieval of captcha ids and solutions for +them, replacing the default memory store. + +It is the responsibility of an object to delete expired and used captchas +when necessary (for example, the default memory store collects them in Set +method after the certain amount of captchas has been stored.) + +### func NewMemoryStore + + func NewMemoryStore(collectNum int, expiration time.Duration) Store + +NewMemoryStore returns a new standard memory store for captchas with the +given collection threshold and expiration time in seconds. The returned +store must be registered with SetCustomStore to replace the default one. diff --git a/capexample/main.go b/capexample/main.go new file mode 100644 index 0000000..ab7e932 --- /dev/null +++ b/capexample/main.go @@ -0,0 +1,83 @@ +// Copyright 2011 Dmitry Chestnykh. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +// example of HTTP server that uses the captcha package. +// +//nolint:forbidigo +package main + +import ( + "fmt" + "io" + "log" + "net/http" + "text/template" + + "code.forgejo.org/go-chi/captcha" +) + +var formTemplate = template.Must(template.New("example").Parse(formTemplateSrc)) + +func showFormHandler(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + d := struct { + CaptchaID string + }{ + captcha.New(), + } + if err := formTemplate.Execute(w, &d); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func processFormHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if !captcha.VerifyString(r.FormValue("captchaId"), r.FormValue("captchaSolution")) { + _, _ = io.WriteString(w, "Wrong captcha solution! No robots allowed!\n") + } else { + _, _ = io.WriteString(w, "Great job, human! You solved the captcha.\n") + } + _, _ = io.WriteString(w, "
Try another one") +} + +func main() { + http.HandleFunc("/", showFormHandler) + http.HandleFunc("/process", processFormHandler) + http.Handle("/captcha/", captcha.Server(captcha.StdWidth, captcha.StdHeight)) + fmt.Println("Server is at localhost:8666") + if err := http.ListenAndServe("localhost:8666", nil); err != nil { + log.Fatal(err) + } +} + +const formTemplateSrc = ` +Captcha Example + + +
+

Type the numbers you see in the picture below:

+

Captcha image

+Reload +
+ + +
+` diff --git a/capgen/example.png b/capgen/example.png new file mode 100644 index 0000000..e290c8e Binary files /dev/null and b/capgen/example.png differ diff --git a/capgen/example.wav b/capgen/example.wav new file mode 100644 index 0000000..31f2f0d Binary files /dev/null and b/capgen/example.wav differ diff --git a/capgen/main.go b/capgen/main.go new file mode 100644 index 0000000..769d924 --- /dev/null +++ b/capgen/main.go @@ -0,0 +1,46 @@ +// Copyright 2011 Dmitry Chestnykh. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +// capgen is an utility to test captcha generation. +// +//nolint:forbidigo +package main + +import ( + "flag" + "fmt" + "os" + + "code.forgejo.org/go-chi/captcha" +) + +var ( + flagLen = flag.Int("len", captcha.DefaultLen, "length of captcha") + flagImgW = flag.Int("width", captcha.StdWidth, "image captcha width") + flagImgH = flag.Int("height", captcha.StdHeight, "image captcha height") +) + +func usage() { + fmt.Fprintf(os.Stderr, "usage: capgen [flags] filename\n") + flag.PrintDefaults() +} + +func main() { + flag.Parse() + fname := flag.Arg(0) + if fname == "" { + usage() + os.Exit(1) + } + f, err := os.Create(fname) + if err != nil { + panic(err) + } + defer f.Close() + d := captcha.RandomDigits(*flagLen) + if _, err := captcha.NewImage("", d, *flagImgW, *flagImgH).WriteTo(f); err != nil { + panic(err) + } + fmt.Println(d) +} diff --git a/captcha.go b/captcha.go new file mode 100644 index 0000000..04137b0 --- /dev/null +++ b/captcha.go @@ -0,0 +1,153 @@ +// Copyright 2011 Dmitry Chestnykh. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +// Package captcha implements generation and verification of image and audio +// CAPTCHAs. +// +// A captcha solution is the sequence of digits 0-9 with the defined length. +// There are two captcha representations: image and audio. +// +// An image representation is a PNG-encoded image with the solution printed on +// it in such a way that makes it hard for computers to solve it using OCR. +// +// An audio representation is a WAVE-encoded (8 kHz unsigned 8-bit) sound with +// the spoken solution (currently in English, Russian, Chinese, and Japanese). +// To make it hard for computers to solve audio captcha, the voice that +// pronounces numbers has random speed and pitch, and there is a randomly +// generated background noise mixed into the sound. +// +// This package doesn't require external files or libraries to generate captcha +// representations; it is self-contained. +// +// To make captchas one-time, the package includes a memory storage that stores +// captcha ids, their solutions, and expiration time. Used captchas are removed +// from the store immediately after calling Verify or VerifyString, while +// unused captchas (user loaded a page with captcha, but didn't submit the +// form) are collected automatically after the predefined expiration time. +// Developers can also provide custom store (for example, which saves captcha +// ids and solutions in database) by implementing Store interface and +// registering the object with SetCustomStore. +// +// Captchas are created by calling New, which returns the captcha id. Their +// representations, though, are created on-the-fly by calling WriteImage or +// WriteAudio functions. Created representations are not stored anywhere, but +// subsequent calls to these functions with the same id will write the same +// captcha solution. Reload function will create a new different solution for +// the provided captcha, allowing users to "reload" captcha if they can't solve +// the displayed one without reloading the whole page. Verify and VerifyString +// are used to verify that the given solution is the right one for the given +// captcha id. +// +// Server provides an http.Handler which can serve image and audio +// representations of captchas automatically from the URL. It can also be used +// to reload captchas. Refer to Server function documentation for details, or +// take a look at the example in "capexample" subdirectory. +package captcha + +import ( + "bytes" + "errors" + "io" + "time" +) + +const ( + // Default number of digits in captcha solution. + DefaultLen = 6 + // The number of captchas created that triggers garbage collection used + // by default store. + CollectNum = 100 + // Expiration time of captchas used by default store. + Expiration = 10 * time.Minute +) + +var ( + ErrNotFound = errors.New("captcha: id not found") + // globalStore is a shared storage for captchas, generated by New function. + globalStore = NewMemoryStore(CollectNum, Expiration) +) + +// SetCustomStore sets custom storage for captchas, replacing the default +// memory store. This function must be called before generating any captchas. +func SetCustomStore(s Store) { + globalStore = s +} + +// New creates a new captcha with the standard length, saves it in the internal +// storage and returns its id. +func New() string { + return NewLen(DefaultLen) +} + +// NewLen is just like New, but accepts length of a captcha solution as the +// argument. +func NewLen(length int) (id string) { + id = randomID() + globalStore.Set(id, RandomDigits(length)) + return +} + +// Reload generates and remembers new digits for the given captcha id. This +// function returns false if there is no captcha with the given id. +// +// After calling this function, the image or audio presented to a user must be +// refreshed to show the new captcha representation (WriteImage and WriteAudio +// will write the new one). +func Reload(id string) bool { + old := globalStore.Get(id, false) + if old == nil { + return false + } + globalStore.Set(id, RandomDigits(len(old))) + return true +} + +// WriteImage writes PNG-encoded image representation of the captcha with the +// given id. The image will have the given width and height. +func WriteImage(w io.Writer, id string, width, height int) error { + d := globalStore.Get(id, false) + if d == nil { + return ErrNotFound + } + _, err := NewImage(id, d, width, height).WriteTo(w) + return err +} + +// Verify returns true if the given digits are the ones that were used to +// create the given captcha id. +// +// The function deletes the captcha with the given id from the internal +// storage, so that the same captcha can't be verified anymore. +func Verify(id string, digits []byte) bool { + if len(digits) == 0 { + return false + } + reald := globalStore.Get(id, true) + if reald == nil { + return false + } + return bytes.Equal(digits, reald) +} + +// VerifyString is like Verify, but accepts a string of digits. It removes +// spaces and commas from the string, but any other characters, apart from +// digits and listed above, will cause the function to return false. +func VerifyString(id string, digits string) bool { + if digits == "" { + return false + } + ns := make([]byte, len(digits)) + for i := range ns { + d := digits[i] + switch { + case '0' <= d && d <= '9': + ns[i] = d - '0' + case d == ' ' || d == ',': + // ignore + default: + return false + } + } + return Verify(id, ns) +} diff --git a/captcha_test.go b/captcha_test.go new file mode 100644 index 0000000..de7f7a9 --- /dev/null +++ b/captcha_test.go @@ -0,0 +1,52 @@ +// Copyright 2011 Dmitry Chestnykh. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package captcha + +import ( + "bytes" + "testing" +) + +func TestNew(t *testing.T) { + c := New() + if c == "" { + t.Errorf("expected id, got empty string") + } +} + +func TestVerify(t *testing.T) { + id := New() + if Verify(id, []byte{0, 0}) { + t.Errorf("verified wrong captcha") + } + id = New() + d := globalStore.Get(id, false) // cheating + if !Verify(id, d) { + t.Errorf("proper captcha not verified") + } +} + +func TestReload(t *testing.T) { + id := New() + d1 := globalStore.Get(id, false) // cheating + Reload(id) + d2 := globalStore.Get(id, false) // cheating again + if bytes.Equal(d1, d2) { + t.Errorf("reload didn't work: %v = %v", d1, d2) + } +} + +func TestRandomDigits(t *testing.T) { + d1 := RandomDigits(10) + for _, v := range d1 { + if v > 9 { + t.Errorf("digits not in range 0-9: %v", d1) + } + } + d2 := RandomDigits(10) + if bytes.Equal(d1, d2) { + t.Errorf("digits seem to be not random") + } +} diff --git a/font.go b/font.go new file mode 100644 index 0000000..2b5fae0 --- /dev/null +++ b/font.go @@ -0,0 +1,215 @@ +// Copyright 2011 Dmitry Chestnykh. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package captcha + +const ( + fontWidth = 11 + fontHeight = 18 + blackChar = 1 +) + +//nolint:dupl +var font = [][]byte{ + { // 0 + 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, + 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, + 0, 1, 1, 1, 0, 0, 0, 1, 1, 1, 0, + 0, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, + 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, + 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, + 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, + 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, + 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, + 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, + 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, + 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, + 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, + 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, + 0, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, + 0, 1, 1, 1, 0, 0, 0, 1, 1, 1, 0, + 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, + 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, + }, + { // 1 + 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, + 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, + 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, + 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, + 0, 0, 1, 1, 0, 1, 1, 0, 0, 0, 0, + 0, 0, 1, 0, 0, 1, 1, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, + 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + }, + { // 2 + 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, + 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, + 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 0, + 0, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, + 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, + 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, + 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, + 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, + 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, + 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, + 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, + 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + }, + { // 3 + 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, + 1, 1, 0, 0, 0, 0, 0, 1, 1, 1, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, + 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, + 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, + 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, + 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, + 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, + }, + { // 4 + 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, + 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, + 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, + 0, 0, 0, 0, 0, 1, 0, 1, 1, 0, 0, + 0, 0, 0, 0, 1, 1, 0, 1, 1, 0, 0, + 0, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, + 0, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, + 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 0, + 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, + 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, + 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 0, + 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, + }, + { // 5 + 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, + 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, + 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, + 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, + 1, 1, 0, 0, 0, 0, 0, 1, 1, 1, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, + 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, + }, + { // 6 + 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, + 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, + 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, + 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 0, + 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 0, + 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 0, + 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1, + 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, + 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, + 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, + 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, + 0, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1, + 0, 1, 1, 1, 0, 0, 0, 1, 1, 1, 0, + 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, + 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, + }, + { // 7 + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, + 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, + 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, + 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, + 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, + 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, + 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, + 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, + 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, + 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, + 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, + 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, + }, + { // 8 + 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, + 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, + 0, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, + 0, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, + 0, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, + 0, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, + 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, + 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, + 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, + 0, 0, 1, 1, 1, 0, 1, 1, 1, 0, 0, + 0, 1, 1, 1, 0, 0, 0, 1, 1, 1, 0, + 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 1, + 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, + 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, + 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, + 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, + 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, + 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, + }, + { // 9 + 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, + 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, + 0, 1, 1, 0, 0, 0, 0, 1, 1, 1, 0, + 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, + 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, + 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, + 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, + 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1, + 0, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, + 0, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, + 0, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, + 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, + 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, + 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, + 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, + }, +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ba2048e --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module code.forgejo.org/go-chi/captcha + +go 1.23 + +toolchain go1.23.4 diff --git a/image.go b/image.go new file mode 100644 index 0000000..ab656b8 --- /dev/null +++ b/image.go @@ -0,0 +1,278 @@ +// Copyright 2011-2014 Dmitry Chestnykh. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package captcha + +import ( + "bytes" + "image" + "image/color" + "image/png" + "io" + "math" + mathRand "math/rand/v2" +) + +const ( + // Standard width and height of a captcha image. + StdWidth = 240 + StdHeight = 80 + // Maximum absolute skew factor of a single digit. + maxSkew = 0.7 + // Number of background circles. + circleCount = 20 +) + +type Image struct { + *image.Paletted + numWidth int + numHeight int + dotSize int + rng *mathRand.Rand +} + +// Int returns a pseudorandom int in the half-open interval [from, to]. +func (m *Image) Int(from, to int) int { + return from + m.rng.IntN(to-from+1) +} + +// Float returns a pseudorandom float64 in the half-open interval [from, to). +func (m *Image) Float(from, to float64) float64 { + return from + (to-from)*m.rng.Float64() +} + +// NewImage returns a new captcha image of the given width and height with the +// given digits, where each digit must be in range 0-9. +func NewImage(id string, digits []byte, width, height int) *Image { + m := new(Image) + + // Initialize PRNG. + m.rng = mathRand.New(mathRand.NewChaCha8(deriveSeed(imageSeedPurpose, id, digits))) + + m.Paletted = image.NewPaletted(image.Rect(0, 0, width, height), m.getRandomPalette()) + m.calculateSizes(width, height, len(digits)) + // Randomly position captcha inside the image. + maxx := width - (m.numWidth+m.dotSize)*len(digits) - m.dotSize + maxy := height - m.numHeight - m.dotSize*2 + var border int + if width > height { + border = height / 5 + } else { + border = width / 5 + } + x := m.Int(border, maxx-border) + y := m.Int(border, maxy-border) + // Draw digits. + for _, n := range digits { + m.drawDigit(font[n], x, y) + x += m.numWidth + m.dotSize + } + // Draw strike-through line. + m.strikeThrough() + // Apply wave distortion. + m.distort(m.Float(5, 10), m.Float(100, 200)) + // Fill image with random circles. + m.fillWithCircles(circleCount, m.dotSize) + // Flip the image horizontally. + m.flipX() + return m +} + +func (m *Image) getRandomPalette() color.Palette { + p := make([]color.Color, circleCount+1) + // Transparent color. + p[0] = color.RGBA{0xFF, 0xFF, 0xFF, 0x00} + // Primary color. + prim := color.RGBA{ + uint8(m.rng.IntN(129)), + uint8(m.rng.IntN(129)), + uint8(m.rng.IntN(129)), + 0xFF, + } + p[1] = prim + // Circle colors. + for i := 2; i <= circleCount; i++ { + p[i] = m.randomBrightness(prim, 255) + } + return p +} + +// encodedPNG encodes an image to PNG and returns +// the result as a byte slice. +func (m *Image) encodedPNG() []byte { + var buf bytes.Buffer + if err := png.Encode(&buf, m.Paletted); err != nil { + panic(err.Error()) + } + return buf.Bytes() +} + +// WriteTo writes captcha image in PNG format into the given writer. +func (m *Image) WriteTo(w io.Writer) (int64, error) { + n, err := w.Write(m.encodedPNG()) + return int64(n), err +} + +func (m *Image) calculateSizes(width, height, ncount int) { + // Goal: fit all digits inside the image. + var border int + if width > height { + border = height / 4 + } else { + border = width / 4 + } + // Convert everything to floats for calculations. + w := float64(width - border*2) + h := float64(height - border*2) + // fw takes into account 1-dot spacing between digits. + fw := float64(fontWidth + 1) + fh := float64(fontHeight) + nc := float64(ncount) + // Calculate the width of a single digit taking into account only the + // width of the image. + nw := w / nc + // Calculate the height of a digit from this width. + nh := nw * fh / fw + // Digit too high? + if nh > h { + // Fit digits based on height. + nh = h + nw = fw / fh * nh + } + // Calculate dot size. + m.dotSize = int(nh / fh) + if m.dotSize < 1 { + m.dotSize = 1 + } + // Save everything, making the actual width smaller by 1 dot to account + // for spacing between digits. + m.numWidth = int(nw) - m.dotSize + m.numHeight = int(nh) +} + +func (m *Image) drawHorizLine(fromX, toX, y int, colorIdx uint8) { + for x := fromX; x <= toX; x++ { + m.SetColorIndex(x, y, colorIdx) + } +} + +func (m *Image) drawCircle(x, y, radius int, colorIdx uint8) { + f := 1 - radius + dfx := 1 + dfy := -2 * radius + xo := 0 + yo := radius + + m.SetColorIndex(x, y+radius, colorIdx) + m.SetColorIndex(x, y-radius, colorIdx) + m.drawHorizLine(x-radius, x+radius, y, colorIdx) + + for xo < yo { + if f >= 0 { + yo-- + dfy += 2 + f += dfy + } + xo++ + dfx += 2 + f += dfx + m.drawHorizLine(x-xo, x+xo, y+yo, colorIdx) + m.drawHorizLine(x-xo, x+xo, y-yo, colorIdx) + m.drawHorizLine(x-yo, x+yo, y+xo, colorIdx) + m.drawHorizLine(x-yo, x+yo, y-xo, colorIdx) + } +} + +func (m *Image) fillWithCircles(n, maxradius int) { + maxx := m.Bounds().Max.X + maxy := m.Bounds().Max.Y + for i := 0; i < n; i++ { + colorIdx := uint8(m.Int(1, circleCount-1)) + r := m.Int(1, maxradius) + m.drawCircle(m.Int(r, maxx-r), m.Int(r, maxy-r), r, colorIdx) + } +} + +func (m *Image) strikeThrough() { + maxx := m.Bounds().Max.X + maxy := m.Bounds().Max.Y + y := m.Int(maxy/3, maxy-maxy/3) + amplitude := m.Float(5, 20) + period := m.Float(80, 180) + dx := 2.0 * math.Pi / period + for x := 0; x < maxx; x++ { + xo := amplitude * math.Cos(float64(y)*dx) + yo := amplitude * math.Sin(float64(x)*dx) + for yn := 0; yn < m.dotSize; yn++ { + r := m.rng.IntN(m.dotSize + 1) + m.drawCircle(x+int(xo), y+int(yo)+(yn*m.dotSize), r/2, 1) + } + } +} + +func (m *Image) drawDigit(digit []byte, x, y int) { + skf := m.Float(-maxSkew, maxSkew) + xs := float64(x) + r := m.dotSize / 2 + y += m.Int(-r, r) + for yo := 0; yo < fontHeight; yo++ { + for xo := 0; xo < fontWidth; xo++ { + if digit[yo*fontWidth+xo] != blackChar { + continue + } + m.drawCircle(x+xo*m.dotSize, y+yo*m.dotSize, r, 1) + } + xs += skf + x = int(xs) + } +} + +func (m *Image) distort(amplude float64, period float64) { + w := m.Bounds().Max.X + h := m.Bounds().Max.Y + + oldm := m.Paletted + newm := image.NewPaletted(image.Rect(0, 0, w, h), oldm.Palette) + + dx := 2.0 * math.Pi / period + for x := 0; x < w; x++ { + for y := 0; y < h; y++ { + xo := amplude * math.Sin(float64(y)*dx) + yo := amplude * math.Cos(float64(x)*dx) + newm.SetColorIndex(x, y, oldm.ColorIndexAt(x+int(xo), y+int(yo))) + } + } + m.Paletted = newm +} + +func (m *Image) flipX() { + w := m.Bounds().Max.X + h := m.Bounds().Max.Y + + src := m.Paletted + dst := image.NewPaletted(image.Rect(0, 0, w, h), src.Palette) + + for x := 0; x < w; x++ { + for y := 0; y < h; y++ { + xo := w - x - 1 + dst.SetColorIndex(x, y, src.ColorIndexAt(xo, y)) + } + } + m.Paletted = dst +} + +func (m *Image) randomBrightness(c color.RGBA, maxB uint8) color.RGBA { + minc := min(min(c.R, c.G), c.B) + maxc := max(max(c.R, c.G), c.B) + if maxc > maxB { + return c + } + n := m.rng.IntN(int(maxB-maxc)) - int(minc) + return color.RGBA{ + uint8(int(c.R) + n), + uint8(int(c.G) + n), + uint8(int(c.B) + n), + c.A, + } +} diff --git a/image_test.go b/image_test.go new file mode 100644 index 0000000..ea44d4f --- /dev/null +++ b/image_test.go @@ -0,0 +1,17 @@ +// Copyright 2011 Dmitry Chestnykh. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package captcha + +import "testing" + +func BenchmarkNewImage(b *testing.B) { + b.StopTimer() + d := RandomDigits(DefaultLen) + id := randomID() + b.StartTimer() + for i := 0; i < b.N; i++ { + NewImage(id, d, StdWidth, StdHeight) + } +} diff --git a/random.go b/random.go new file mode 100644 index 0000000..478af3b --- /dev/null +++ b/random.go @@ -0,0 +1,109 @@ +// Copyright 2011-2014 Dmitry Chestnykh. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package captcha + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "io" +) + +// idLen is a length of captcha id string. +// (20 bytes of 62-letter alphabet give ~119 bits.) +const idLen = 20 + +// idChars are characters allowed in captcha id. +var idChars = []byte("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") + +// rngKey is a secret key used to deterministically derive seeds for +// PRNGs used in image. Generated once during initialization. +var rngKey [32]byte + +func init() { + if _, err := io.ReadFull(rand.Reader, rngKey[:]); err != nil { + panic("captcha: error reading random source: " + err.Error()) + } +} + +// Purposes for seed derivation. The goal is to make deterministic PRNG produce +// different outputs for images and audio by using different derived seeds. +const ( + imageSeedPurpose = 0x01 + audioSeedPurpose = 0x02 +) + +// deriveSeed returns a 16-byte PRNG seed from rngKey, purpose, id and digits. +// Same purpose, id and digits will result in the same derived seed for this +// instance of running application. +// +// out = HMAC(rngKey, purpose || id || 0x00 || digits) +func deriveSeed(purpose byte, id string, digits []byte) (out [32]byte) { + var buf [sha256.Size]byte + + h := hmac.New(sha256.New, rngKey[:]) + h.Write([]byte{purpose}) + h.Write([]byte(id)) + h.Write([]byte{0}) + h.Write(digits) + + sum := h.Sum(buf[:0]) + copy(out[:], sum) + return +} + +// RandomDigits returns a byte slice of the given length containing +// pseudorandom numbers in range 0-9. The slice can be used as a captcha +// solution. +func RandomDigits(length int) []byte { + return randomBytesMod(length, 10) +} + +// randomBytes returns a byte slice of the given length read from CSPRNG. +func randomBytes(length int) (b []byte) { + b = make([]byte, length) + if _, err := io.ReadFull(rand.Reader, b); err != nil { + panic("captcha: error reading random source: " + err.Error()) + } + return +} + +// randomBytesMod returns a byte slice of the given length, where each byte is +// a random number modulo mod. +func randomBytesMod(length int, mod byte) (b []byte) { + if length == 0 { + return nil + } + if mod == 0 { + panic("captcha: bad mod argument for randomBytesMod") + } + + maxrb := 255 - byte(256%int(mod)) + b = make([]byte, length) + i := 0 + for { + r := randomBytes(length + (length / 4)) + for _, c := range r { + if c > maxrb { + // Skip this number to avoid modulo bias. + continue + } + b[i] = c % mod + i++ + if i == length { + return + } + } + } +} + +// randomID returns a new random id string. +func randomID() string { + b := randomBytesMod(idLen, byte(len(idChars))) + for i, c := range b { + b[i] = idChars[c] + } + return string(b) +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..3146f63 --- /dev/null +++ b/renovate.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["go-chi/renovate-config"] +} diff --git a/server.go b/server.go new file mode 100644 index 0000000..4d1518c --- /dev/null +++ b/server.go @@ -0,0 +1,84 @@ +// Copyright 2011 Dmitry Chestnykh. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package captcha + +import ( + "bytes" + "net/http" + "path" + "time" +) + +type captchaHandler struct { + imgWidth int + imgHeight int +} + +// Server returns a handler that serves HTTP requests with image +// representations of captchas. Image dimensions are accepted as +// arguments. The server decides which captcha to serve based on the last URL +// path component: file name part must contain a captcha id, file extension — +// its format (PNG or WAV). +// +// For example, for file name "LBm5vMjHDtdUfaWYXiQX.png" it serves an image captcha +// with id "LBm5vMjHDtdUfaWYXiQX", and for "LBm5vMjHDtdUfaWYXiQX.wav" it serves the +// same captcha in audio format. +// +// To serve a captcha as a downloadable file, the URL must be constructed in +// such a way as if the file to serve is in the "download" subdirectory: +// "/download/LBm5vMjHDtdUfaWYXiQX.wav". +// +// To reload captcha (get a different solution for the same captcha id), append +// "?reload=x" to URL, where x may be anything (for example, current time or a +// random number to make browsers refetch an image instead of loading it from +// cache). +// +// By default, the Server serves audio in English language. To serve audio +// captcha in one of the other supported languages, append "lang" value, for +// example, "?lang=ru". +func Server(imgWidth, imgHeight int) http.Handler { + return &captchaHandler{imgWidth, imgHeight} +} + +func (h *captchaHandler) serve(w http.ResponseWriter, r *http.Request, id, ext string, download bool) error { + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") + + var content bytes.Buffer + switch ext { + case ".png": + w.Header().Set("Content-Type", "image/png") + if err := WriteImage(&content, id, h.imgWidth, h.imgHeight); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + default: + return ErrNotFound + } + + if download { + w.Header().Set("Content-Type", "application/octet-stream") + } + http.ServeContent(w, r, id+ext, time.Time{}, bytes.NewReader(content.Bytes())) + return nil +} + +func (h *captchaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + dir, file := path.Split(r.URL.Path) + ext := path.Ext(file) + id := file[:len(file)-len(ext)] + if ext == "" || id == "" { + http.NotFound(w, r) + return + } + if r.FormValue("reload") != "" { + Reload(id) + } + download := path.Base(dir) == "download" + if h.serve(w, r, id, ext, download) == ErrNotFound { + http.NotFound(w, r) + } + // Ignore other errors. +} diff --git a/store.go b/store.go new file mode 100644 index 0000000..9fc4497 --- /dev/null +++ b/store.go @@ -0,0 +1,117 @@ +// Copyright 2011 Dmitry Chestnykh. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package captcha + +import ( + "container/list" + "sync" + "time" +) + +// An object implementing Store interface can be registered with SetCustomStore +// function to handle storage and retrieval of captcha ids and solutions for +// them, replacing the default memory store. +// +// It is the responsibility of an object to delete expired and used captchas +// when necessary (for example, the default memory store collects them in Set +// method after the certain amount of captchas has been stored.) +type Store interface { + // Set sets the digits for the captcha id. + Set(id string, digits []byte) + + // Get returns stored digits for the captcha id. Clear indicates + // whether the captcha must be deleted from the store. + Get(id string, clear bool) (digits []byte) +} + +// expValue stores timestamp and id of captchas. It is used in the list inside +// memoryStore for indexing generated captchas by timestamp to enable garbage +// collection of expired captchas. +type idByTimeValue struct { + timestamp time.Time + id string +} + +// memoryStore is an internal store for captcha ids and their values. +type memoryStore struct { + sync.RWMutex + digitsByID map[string][]byte + idByTime *list.List + // Number of items stored since last collection. + numStored int + // Number of saved items that triggers collection. + collectNum int + // Expiration time of captchas. + expiration time.Duration +} + +// NewMemoryStore returns a new standard memory store for captchas with the +// given collection threshold and expiration time (duration). The returned +// store must be registered with SetCustomStore to replace the default one. +func NewMemoryStore(collectNum int, expiration time.Duration) Store { + s := new(memoryStore) + s.digitsByID = make(map[string][]byte) + s.idByTime = list.New() + s.collectNum = collectNum + s.expiration = expiration + return s +} + +func (s *memoryStore) Set(id string, digits []byte) { + s.Lock() + s.digitsByID[id] = digits + s.idByTime.PushBack(idByTimeValue{time.Now(), id}) + s.numStored++ + if s.numStored <= s.collectNum { + s.Unlock() + return + } + s.Unlock() + go s.collect() +} + +func (s *memoryStore) Get(id string, clear bool) (digits []byte) { + if !clear { + // When we don't need to clear captcha, acquire read lock. + s.RLock() + defer s.RUnlock() + } else { + s.Lock() + defer s.Unlock() + } + digits, ok := s.digitsByID[id] + if !ok { + return + } + if clear { + delete(s.digitsByID, id) + // XXX(dchest) Index (s.idByTime) will be cleaned when + // collecting expired captchas. Can't clean it here, because + // we don't store reference to expValue in the map. + // Maybe store it? + } + return +} + +func (s *memoryStore) collect() { + now := time.Now() + s.Lock() + defer s.Unlock() + s.numStored = 0 + for e := s.idByTime.Front(); e != nil; { + ev, ok := e.Value.(idByTimeValue) + if !ok { + return + } + if ev.timestamp.Add(s.expiration).Before(now) { + delete(s.digitsByID, ev.id) + next := e.Next() + s.idByTime.Remove(e) + e = next + } else { + return + } + } +} diff --git a/store_test.go b/store_test.go new file mode 100644 index 0000000..6067292 --- /dev/null +++ b/store_test.go @@ -0,0 +1,79 @@ +// Copyright 2011 Dmitry Chestnykh. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package captcha + +import ( + "bytes" + "testing" +) + +func TestSetGet(t *testing.T) { + s := NewMemoryStore(CollectNum, Expiration) + id := "captcha id" + d := RandomDigits(10) + s.Set(id, d) + d2 := s.Get(id, false) + if d2 == nil || !bytes.Equal(d, d2) { + t.Errorf("saved %v, getDigits returned got %v", d, d2) + } +} + +func TestGetClear(t *testing.T) { + s := NewMemoryStore(CollectNum, Expiration) + id := "captcha id" + d := RandomDigits(10) + s.Set(id, d) + d2 := s.Get(id, true) + if d2 == nil || !bytes.Equal(d, d2) { + t.Errorf("saved %v, getDigitsClear returned got %v", d, d2) + } + d2 = s.Get(id, false) + if d2 != nil { + t.Errorf("getDigitClear didn't clear (%q=%v)", id, d2) + } +} + +func TestCollect(t *testing.T) { + // TODO(dchest): can't test automatic collection when saving, because + // it's currently launched in a different goroutine. + s := NewMemoryStore(10, -1) + // create 10 ids + ids := make([]string, 10) + d := RandomDigits(10) + for i := range ids { + ids[i] = randomID() + s.Set(ids[i], d) + } + s.(*memoryStore).collect() + // Must be already collected + nc := 0 + for i := range ids { + d2 := s.Get(ids[i], false) + if d2 != nil { + t.Errorf("%d: not collected", i) + nc++ + } + } + if nc > 0 { + t.Errorf("= not collected %d out of %d captchas", nc, len(ids)) + } +} + +func BenchmarkSetCollect(b *testing.B) { + b.StopTimer() + d := RandomDigits(10) + s := NewMemoryStore(9999, -1) + ids := make([]string, 1000) + for i := range ids { + ids[i] = randomID() + } + b.StartTimer() + for i := 0; i < b.N; i++ { + for j := 0; j < 1000; j++ { + s.Set(ids[j], d) + } + s.(*memoryStore).collect() + } +}