commit 21b2f24986cf86bb484ea0f2aa024c9272fa6099 Author: Alexander "Arav" Andreev Date: Fri Jun 24 23:09:46 2022 +0400 Initial commit with a fully working program, lel. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6cc02a9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +bin/* +!bin/.keep +.vscode +*.test.yaml \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..44428c2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License + +Copyright (c) 2022 Alexander "Arav" Andreev + +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. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100755 index 0000000..4b41e8f --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +TARGET=justcaptcha +DAEMON_NAME=${TARGET}d + +SYSCTL=${shell which systemctl} +SYSDDIR_=${shell pkg-config systemd --variable=systemdsystemunitdir} +SYSDDIR=${SYSDDIR_:/%=%} +DESTDIR=/ + +LDFLAGS=-ldflags "-s -w" -tags osusergo,netgo + +all: ${DAEMON_NAME} + +.PHONY: ${DAEMON_NAME} + +${DAEMON_NAME}: + go build -o bin/$@ ${LDFLAGS} cmd/$@/main.go + +run-test: + bin/${DAEMON_NAME} -expire 1m -listen 127.0.0.1:19134 + +install: + install -Dm 0755 bin/${DAEMON_NAME} ${DESTDIR}usr/bin/${DAEMON_NAME} + install -Dm 0644 LICENSE ${DESTDIR}usr/share/licenses/${TARGET}/LICENSE + + install -Dm 0644 init/systemd/${TARGET}.service ${DESTDIR}${SYSDDIR}/${TARGET}.service + +uninstall: + rm ${DESTDIR}usr/bin/${DAEMON_NAME} + rm ${DESTDIR}usr/share/licenses/${TARGET}/LICENSE + + rm ${DESTDIR}${SYSDDIR}/${TARGET}.service + +clean: + go clean diff --git a/README.md b/README.md new file mode 100644 index 0000000..6af8a1f --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +justcaptcha ver. 1.0 +==================== + +A simple CAPTCHA service implementation. + +## API + +### Get a new captcha + + GET / + +It will return an ID of a new captcha in plaintext. + +#### HTTP codes +- 200 if created + +### Get an image for a captcha + + GET /:captcha_id/image + +Responds with an image (e.g. in PNG format). + +#### HTTP codes +- 200 if exists +- 404 if doesn't exist + +### Submit an answer + + POST /:captcha_id + +It takes one form-data parameter `answer=123456`. + +Responds with empty body and one of HTTP codes. + +#### HTTP codes +- 200 if solved +- 403 if not solved +- 404 if doesn't exist + +### Check if captcha is solved + + GET /:captcha_id + +Responds with empty body and one of HTTP codes. + +#### HTTP codes +- 200 if solved +- 403 if not solved +- 404 if doesn't exist \ No newline at end of file diff --git a/bin/.keep b/bin/.keep new file mode 100644 index 0000000..e69de29 diff --git a/build/archlinux/PKGBUILD b/build/archlinux/PKGBUILD new file mode 100644 index 0000000..a51c7f8 --- /dev/null +++ b/build/archlinux/PKGBUILD @@ -0,0 +1,30 @@ +# Maintainer: Alexander "Arav" Andreev +pkgname=justcaptcha-git +pkgver=1.0 +pkgrel=1 +pkgdesc="Just a CAPTCHA service" +arch=('i686' 'x86_64' 'arm' 'armv6h' 'armv7h' 'aarch64') +url="https://git.arav.top/Arav/justcaptcha" +license=('MIT') +groups=() +depends=() +makedepends=('git' 'go') +provides=('justcaptcha') +conflicts=('justcaptcha') +replaces=() +backup=() +options=() +install= +source=('justcaptcha-git::git+https://git.arav.top/Arav/justcaptcha.git') +noextract=() +md5sums=('SKIP') + +build() { + cd "$srcdir/$pkgname" + make DESTDIR="$pkgdir/" +} + +package() { + cd "$srcdir/$pkgname" + make DESTDIR="$pkgdir/" install +} \ No newline at end of file diff --git a/cmd/justcaptchad/main.go b/cmd/justcaptchad/main.go new file mode 100644 index 0000000..d0ab5cc --- /dev/null +++ b/cmd/justcaptchad/main.go @@ -0,0 +1,79 @@ +package main + +import ( + "flag" + "fmt" + "justcaptcha/internal/captcha" + "justcaptcha/internal/handlers" + "justcaptcha/pkg/server" + "log" + "net/netip" + "os" + "os/signal" + "strings" + "syscall" + "time" +) + +var listenAddress *string = flag.String("listen", "/var/run/captcha/c.sock", "listen address (ip:port|unix_path)") +var captchaExpire *time.Duration = flag.Duration("expire", 5*time.Minute, "CAPTCHA expiration in format XX{s,m,h}, e.g. 5m, 300s") +var showVersion *bool = flag.Bool("v", false, "show version") + +func main() { + flag.Parse() + log.SetFlags(0) + + if *showVersion { + fmt.Println("justcaptchad ver. 1.0\nCopyright (c) 2022 Alexander \"Arav\" Andreev ") + return + } + + defer func() { + if !strings.ContainsRune(*listenAddress, ':') { + os.Remove(*listenAddress) + } + }() + + captcha.Init(*captchaExpire) + + hand := handlers.New(*captchaExpire) + srv := server.NewHttpServer() + + srv.GET("/", hand.New) + srv.POST("/:captcha", hand.Solve) + srv.GET("/:captcha", hand.IsSolved) + srv.GET("/:captcha/image", hand.Image) + + var network string + if !strings.ContainsRune(*listenAddress, ':') { + network = "unix" + } else { + ap, err := netip.ParseAddrPort(*listenAddress) + if err != nil { + log.Fatalln(err) + } + + if !ap.IsValid() { + log.Fatalln("ip and/or port provided are not valid") + } + + if ap.Addr().Is4() { + network = "tcp4" + } else if ap.Addr().Is6() { + network = "tcp6" + } + } + + if err := srv.Start(network, *listenAddress); err != nil { + log.Fatalln("failed to start a server:", err) + } + + doneSignal := make(chan os.Signal, 1) + signal.Notify(doneSignal, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) + + <-doneSignal + + if err := srv.Stop(); err != nil { + log.Fatalln("failed to properly shutdown a server:", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b217808 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module justcaptcha + +go 1.17 + +require ( + github.com/fogleman/gg v1.3.0 + github.com/julienschmidt/httprouter v1.3.0 + github.com/pkg/errors v0.9.1 +) + +require ( + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect + golang.org/x/image v0.0.0-20220617043117-41969df76e82 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..dc5dcf5 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +golang.org/x/image v0.0.0-20220617043117-41969df76e82 h1:KpZB5pUSBvrHltNEdK/tw0xlPeD13M6M6aGP32gKqiw= +golang.org/x/image v0.0.0-20220617043117-41969df76e82/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/init/systemd/justcaptcha.service b/init/systemd/justcaptcha.service new file mode 100755 index 0000000..1a92d45 --- /dev/null +++ b/init/systemd/justcaptcha.service @@ -0,0 +1,39 @@ +[Unit] +Description=justcaptcha +After=network.target + +[Service] +Type=simple +Restart=on-failure +DynamicUser=yes +ExecStart=/usr/bin/justcaptchad -expire 5m -listen '/var/run/justcaptcha/j.sock' + +ReadOnlyPaths=/ +# Set here path to directory where uploads are stored. +NoExecPaths=/ +ExecPaths=/usr/bin/justcaptchad + +RuntimeDirectory=justcaptcha + +AmbientCapabilities= +CapabilityBoundingSet= + +LockPersonality=true +MemoryDenyWriteExecute=true +NoNewPrivileges=true +PrivateDevices=true +ProtectClock=true +ProtectControlGroups=true +ProtectHome=true +ProtectKernelLogs=true +ProtectKernelModules=true +ProtectKernelTunables=true +ProtectSystem=strict +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +RestrictNamespaces=true +RestrictRealtime=true +RestrictSUIDSGID=true +SystemCallArchitectures=native + +[Install] +WantedBy=multi-user.target diff --git a/internal/captcha/captcha.go b/internal/captcha/captcha.go new file mode 100644 index 0000000..efe6eb1 --- /dev/null +++ b/internal/captcha/captcha.go @@ -0,0 +1,44 @@ +package captcha + +import ( + "crypto/rand" + "image" + "math/big" + "time" + + "github.com/pkg/errors" +) + +const ( + maxAnswer = 999999 +) + +var errorNotFound = errors.New("captcha not found") + +type ID string +type Answer string + +type ICaptcha interface { + GenerateImage() *image.Image + GetImage() *image.Image + GetAnswer() Answer + Solve(answer Answer) bool + IsSolved() bool + Expire() time.Time +} + +type Captcha struct { + Image image.Image + Answer Answer + Solved bool + ExpireIn time.Time +} + +func GenerateAnswer() Answer { + ans, _ := rand.Int(rand.Reader, big.NewInt(maxAnswer)) + return (Answer(ans.String())) +} + +func ExpireDate(expiration time.Duration) time.Time { + return time.Now().Add(expiration) +} diff --git a/internal/captcha/db.go b/internal/captcha/db.go new file mode 100644 index 0000000..14df7f8 --- /dev/null +++ b/internal/captcha/db.go @@ -0,0 +1,104 @@ +package captcha + +import ( + "crypto/sha256" + "encoding/base64" + "image" + "strconv" + "sync" + "time" +) + +var expiredScanInterval = 60 * time.Second + +type ICaptchaDB interface { + New(data string) (ICaptcha, ID) + SetExpiration(expire time.Duration) + Image(id ID) (*image.Image, error) + Solve(id ID, answer Answer) (bool, error) + IsSolved(id ID) bool +} + +type CaptchaDB struct { + DB map[ID]ICaptcha + ExpireInterval time.Duration + mut sync.Mutex +} + +// SetExpiration stores expire value and starts a goroutine +// that checks for expired CAPTCHAs every minute. +func (cdb *CaptchaDB) SetExpiration(expire time.Duration) { + cdb.ExpireInterval = expire + if expire < expiredScanInterval { + expiredScanInterval = expire + } + + go func() { + for { + n := time.Now().Second() + sleepFor := expiredScanInterval - (time.Duration(n) * time.Second % expiredScanInterval) + time.Sleep(sleepFor) + + for id, captcha := range cdb.DB { + if time.Now().Sub(captcha.Expire()) <= 0 { + delete(cdb.DB, id) + } + } + } + }() +} + +// New accepts an ICaptcha instance, generates an ID and store it in a database. +// `data` string is an additional random data used to generate an ID, +// e.g. IP-address. +func (cdb *CaptchaDB) New(data string, captcha ICaptcha) (ICaptcha, ID) { + idHash := sha256.New() + + idHash.Write([]byte(data)) + idHash.Write([]byte(strconv.FormatInt(time.Now().UnixMicro(), 16))) + idHash.Write([]byte(captcha.GetAnswer())) + + id := ID(base64.RawURLEncoding.EncodeToString(idHash.Sum(nil))) + + cdb.mut.Lock() + defer cdb.mut.Unlock() + cdb.DB[id] = captcha + + return captcha, id +} + +// Image returns image for a captcha. +func (cdb *CaptchaDB) Image(id ID) (*image.Image, error) { + if c, ok := cdb.DB[id]; ok { + return c.GetImage(), nil + } + return nil, errorNotFound +} + +// Solve compares given answer with a stored one and if failed +// deletes a captcha from database. +func (cdb *CaptchaDB) Solve(id ID, answer Answer) (bool, error) { + cdb.mut.Lock() + defer cdb.mut.Unlock() + if c, ok := cdb.DB[id]; ok { + ok = c.Solve(answer) + if !ok { + delete(cdb.DB, id) + } + return ok, nil + } + return false, errorNotFound +} + +// IsSolved checks if captcha was solved and removes it +// from a database. +func (cdb *CaptchaDB) IsSolved(id ID) (bool, error) { + cdb.mut.Lock() + defer cdb.mut.Unlock() + if c, ok := cdb.DB[id]; ok { + ok = c.IsSolved() + delete(cdb.DB, id) + return ok, nil + } + return false, errorNotFound +} diff --git a/internal/captcha/dwelling_captcha.go b/internal/captcha/dwelling_captcha.go new file mode 100644 index 0000000..1984507 --- /dev/null +++ b/internal/captcha/dwelling_captcha.go @@ -0,0 +1,91 @@ +package captcha + +import ( + "crypto/rand" + "image" + "math/big" + "time" + + "github.com/fogleman/gg" +) + +const ( + dwImageWidth = 160 + dwImageHeight = 40 +) + +type DwellingCaptcha struct { + Captcha +} + +func NewDwellingCaptcha(expiration time.Duration) *DwellingCaptcha { + return &DwellingCaptcha{ + Captcha: Captcha{ + Answer: GenerateAnswer(), + ExpireIn: ExpireDate(expiration)}} +} + +func (c *DwellingCaptcha) GenerateImage() *image.Image { + ctx := gg.NewContext(dwImageWidth, dwImageHeight) + + // fill background + ctx.SetRGB255(0x0a, 0x0a, 0x0a) + ctx.Clear() + + // draw text + ctx.SetRGB255(0xf5, 0xf5, 0xf5) + ctx.Scale(4.0, 2.7) + ctx.DrawStringAnchored(string(c.GetAnswer()), 20, 5, .5, .5) + + // draw lines and points + + ctx.SetRGB255(0x9f, 0x2b, 0x68) + for i := 0; i < 16; i++ { + x0, _ := rand.Int(rand.Reader, big.NewInt(int64(ctx.Height()))) + x1, _ := rand.Int(rand.Reader, big.NewInt(int64(ctx.Height()))) + ctx.SetLineWidth(2) + r, _ := rand.Int(rand.Reader, big.NewInt(int64(4))) + ctx.DrawPoint(float64(x0.Int64()), float64(x1.Int64()), float64(r.Int64())) + ctx.DrawLine(float64(x0.Int64()), 0, float64(ctx.Height()), float64(x1.Int64())) + ctx.Stroke() + } + + for i := 0; i < 16; i++ { + x0, _ := rand.Int(rand.Reader, big.NewInt(int64(ctx.Height()))) + x1, _ := rand.Int(rand.Reader, big.NewInt(int64(ctx.Height()))) + ctx.SetLineWidth(2) + ctx.DrawLine(0, float64(x0.Int64()), float64(x1.Int64()), float64(ctx.Height())) + r, _ := rand.Int(rand.Reader, big.NewInt(int64(4))) + ctx.DrawPoint(float64(x0.Int64()), float64(x1.Int64()), float64(r.Int64())) + ctx.Stroke() + } + + c.Image = ctx.Image() + + return &c.Image +} + +func (c *DwellingCaptcha) GetImage() *image.Image { + if c.Image == nil { + return c.GenerateImage() + } + + return &c.Image +} + +func (c *DwellingCaptcha) Solve(answer Answer) bool { + c.Solved = c.Answer == answer + return c.Solved +} + +func (c *DwellingCaptcha) GetAnswer() Answer { + return c.Answer +} + +func (c *DwellingCaptcha) IsSolved() bool { + return c.Solved +} + +func (c *DwellingCaptcha) Expire() time.Time { + return c.ExpireIn +} diff --git a/internal/captcha/instance.go b/internal/captcha/instance.go new file mode 100644 index 0000000..934b8c5 --- /dev/null +++ b/internal/captcha/instance.go @@ -0,0 +1,29 @@ +package captcha + +import ( + "image" + "time" +) + +var captchaDb CaptchaDB = CaptchaDB{ + DB: make(map[ID]ICaptcha)} + +func Init(expiration time.Duration) { + captchaDb.SetExpiration(expiration) +} + +func New(data string, captcha ICaptcha) (ICaptcha, ID) { + return captchaDb.New(data, captcha) +} + +func Image(id ID) (*image.Image, error) { + return captchaDb.Image(id) +} + +func Solve(id ID, answer Answer) (bool, error) { + return captchaDb.Solve(id, answer) +} + +func IsSolved(id ID) (bool, error) { + return captchaDb.IsSolved(id) +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go new file mode 100644 index 0000000..1e58ecf --- /dev/null +++ b/internal/handlers/handlers.go @@ -0,0 +1,76 @@ +package handlers + +import ( + "fmt" + "image/png" + "justcaptcha/internal/captcha" + "justcaptcha/pkg/server" + "net/http" + "time" +) + +type CaptchaHandlers struct { + ExpireIn time.Duration +} + +func New(expiration time.Duration) *CaptchaHandlers { + return &CaptchaHandlers{ + ExpireIn: expiration} +} + +func (h *CaptchaHandlers) New(w http.ResponseWriter, r *http.Request) { + dc := captcha.NewDwellingCaptcha(h.ExpireIn) + _, id := captcha.New(r.RemoteAddr, dc) + + fmt.Fprint(w, id) +} + +func (h *CaptchaHandlers) Image(w http.ResponseWriter, r *http.Request) { + captchaID := captcha.ID(server.GetURLParam(r, "captcha")) + + captchaImage, err := captcha.Image(captchaID) + if err != nil { + w.WriteHeader(http.StatusNotFound) + return + } + + if captchaImage == nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.Header().Add("Content-Disposition", "inline; filename=\""+string(captchaID)+"\"") + + png.Encode(w, *captchaImage) +} + +func (h *CaptchaHandlers) Solve(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + + captchaID := captcha.ID(server.GetURLParam(r, "captcha")) + answer := captcha.Answer(r.FormValue("answer")) + + ok, err := captcha.Solve(captcha.ID(captchaID), answer) + if err != nil { + w.WriteHeader(http.StatusNotFound) + return + } + + if !ok { + w.WriteHeader(http.StatusForbidden) + } +} + +func (h *CaptchaHandlers) IsSolved(w http.ResponseWriter, r *http.Request) { + captchaID := captcha.ID(server.GetURLParam(r, "captcha")) + + solved, err := captcha.IsSolved(captchaID) + if err != nil { + w.WriteHeader(http.StatusNotFound) + return + } + + if !solved { + w.WriteHeader(http.StatusForbidden) + } +} diff --git a/pkg/server/http.go b/pkg/server/http.go new file mode 100644 index 0000000..37d59eb --- /dev/null +++ b/pkg/server/http.go @@ -0,0 +1,76 @@ +package server + +import ( + "context" + "log" + "net" + "net/http" + "os" + "time" + + "github.com/julienschmidt/httprouter" +) + +type HttpServer struct { + server *http.Server + router *httprouter.Router +} + +func NewHttpServer() *HttpServer { + r := httprouter.New() + return &HttpServer{ + server: &http.Server{ + ReadTimeout: 3 * time.Second, + WriteTimeout: 3 * time.Second, + Handler: r, + }, + router: r, + } +} + +func (s *HttpServer) GET(path string, handler http.HandlerFunc) { + s.router.Handler(http.MethodGet, path, handler) +} + +func (s *HttpServer) POST(path string, handler http.HandlerFunc) { + s.router.Handler(http.MethodPost, path, handler) +} + +func (s *HttpServer) SetNotFoundHandler(handler http.HandlerFunc) { + s.router.NotFound = handler +} + +// GetURLParam wrapper around underlying router for getting URL parameters. +func GetURLParam(r *http.Request, param string) string { + return httprouter.ParamsFromContext(r.Context()).ByName(param) +} + +func (s *HttpServer) Start(network, address string) error { + listener, err := net.Listen(network, address) + if err != nil { + return err + } + + if listener.Addr().Network() == "unix" { + os.Chmod(address, 0777) + } + + go func() { + if err = s.server.Serve(listener); err != nil && err != http.ErrServerClosed { + log.Fatalln(err) + } + }() + + return nil +} + +func (s *HttpServer) Stop() error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := s.server.Shutdown(ctx); err != nil { + return err + } + + return nil +}