Compare commits

...

3 Commits

15 changed files with 11 additions and 430 deletions

View File

@ -1,33 +0,0 @@
PACKAGE_NAME=justcaptcha
TARGET=${PACKAGE_NAME}d
SYSCTL=${shell which systemctl}
SYSDDIR_=${shell pkg-config systemd --variable=systemdsystemunitdir}
SYSDDIR=${SYSDDIR_:/%=%}
DESTDIR=/
VERSION=2.0.2
LDFLAGS=-ldflags "-s -w -X main.version=${VERSION}" -tags osusergo,netgo
all: ${TARGET}
.PHONY: ${TARGET}
${TARGET}:
go build -o bin/$@ ${LDFLAGS} cmd/$@/main.go
run:
bin/${TARGET} -expiry 1m -listen 127.0.0.1:19134
install:
install -Dm 0755 bin/${TARGET} ${DESTDIR}usr/bin/${TARGET}
install -Dm 0644 LICENSE ${DESTDIR}usr/share/licenses/${PACKAGE_NAME}/LICENSE
install -Dm 0644 init/systemd.service ${DESTDIR}${SYSDDIR}/${PACKAGE_NAME}.service
uninstall:
rm ${DESTDIR}usr/bin/${TARGET}
rm ${DESTDIR}usr/share/licenses/${PACKAGE_NAME}/LICENSE
rm ${DESTDIR}${SYSDDIR}/${PACKAGE_NAME}.service

100
README.md
View File

@ -1,105 +1,9 @@
justcaptcha
===========
A simple CAPTCHA service implementation.
A simple CAPTCHA implementation.
## Service usage
justcaptchad -expiry 10m -listen /var/run/justcaptcha/sock
`-expiry` takes time for CAPTCHA to be valid for in format X{s,m,h}.
`-listen` is `ip:port` or `/path/to/unix.sock` to listen on.
## Service HTTP API
### Errors
All error codes are returned with an error message in `text/plain`.
### Create a new CAPTCHA
POST /
It will return an ID of a new CAPTCHA in `text/plain`.
#### HTTP codes
- `201` if created (always being created)
### Get an image for a CAPTCHA
GET /:captcha_id/image?style=
Responds with an image in JPEG format (`image/jpeg`).
An optional URL parameter `style=` set a name of a CAPTCHA style if it is
supported by CAPTCHA implementation. E.g. `style=dark`.
#### HTTP codes
- `200` if exists
- `404` if doesn't exist
- `500` if for some reason an image wasn't created
### Submit an answer
POST /:captcha_id
Accepts `application/x-www-form-urlencoded` content type.
It takes one parameter `answer=123456`.
Responds with an empty body and one of the HTTP codes.
#### HTTP codes
- `202` if solved
- `403` if not solved
### Check if captcha is solved
GET /:captcha_id?remove
Responds with an empty body and one of the HTTP codes.
If an optional `remove` parameter without a value supplied CAPTCHA will be
removed without checking and a HTTP code `204` will be sent. Otherwise, a `403`
HTTP code will be sent if it is not solved.
A `remove` parameter was added because browsers will print an error in a console
if HTTP code is not within `2xx`, so to keep a console clean you can provide
this parameter.
This can be useful to remove an unused CAPTCHA from a DB without waiting for it
to be expired. E.g. when a visitor requests for a new CAPTCHA or leaving a page.
#### HTTP codes
- `204` if solved
- `403` if not solved
### Example of interaction
First a client makes a POST request with empty body to create a new CAPTCHA and obtains an ID for it.
POST /
As a result we get an ID `n60f2K9JiD5c4qX9MYe90A54nT0nnJrtgfhAjfaWtBg`.
Then a client requests an image for a new CAPTCHA. E.g. with a dark style.
GET /n60f2K9JiD5c4qX9MYe90A54nT0nnJrtgfhAjfaWtBg/image?style=dark
Then a client submits an answer for a CAPTCHA.
POST 'answer=198807' /n60f2K9JiD5c4qX9MYe90A54nT0nnJrtgfhAjfaWtBg
And if answer was correct a client gets a HTTP code 202. Or 403 otherwise.
Then a server checks if CAPTCHA was solved with following request.
GET /n60f2K9JiD5c4qX9MYe90A54nT0nnJrtgfhAjfaWtBg
## Library usage
A simple example using built-in dwelling CAPTCHA implementation.
An example using built-in "dwelling" implementation.
Create a new CAPTCHA:

View File

@ -1,23 +0,0 @@
# Maintainer: Alexander "Arav" Andreev <me@arav.su>
pkgname=justcaptcha
pkgver=2.0.2
pkgrel=2
pkgdesc="Just a standalone simple CAPTCHA service"
arch=('i686' 'x86_64' 'arm' 'armv6h' 'armv7h' 'aarch64')
url="https://git.arav.su/Arav/justcaptcha"
license=('MIT')
makedepends=('go')
provides=('justcaptcha')
conflicts=('justcaptcha')
source=("https://git.arav.su/Arav/justcaptcha/archive/v${pkgver}.tar.gz")
md5sums=('SKIP')
build() {
cd "$srcdir/$pkgname"
make VERSION=$pkgver DESTDIR="$pkgdir/"
}
package() {
cd "$srcdir/$pkgname"
make VERSION=$pkgver DESTDIR="$pkgdir/" install
}

View File

@ -1,69 +0,0 @@
package main
import (
"flag"
"fmt"
"log"
"net/netip"
"os"
"os/signal"
"strings"
"syscall"
"time"
"git.arav.su/Arav/justcaptcha/internal/http"
)
var version string
var listenAddress *string = flag.String("listen", "/var/run/justcaptcha/sock", "listen address (ip:port|unix_path)")
var captchaExpiry *time.Duration = flag.Duration("expiry", 10*time.Minute, "CAPTCHA expiry 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.", version, "\nCopyright (c) 2022 Alexander \"Arav\" Andreev <me@arav.su>")
return
}
hand := http.NewCaptchaHandlers(*captchaExpiry)
srv := http.NewHttpServer()
srv.POST("/", 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"
defer os.Remove(*listenAddress)
} else {
ap, err := netip.ParseAddrPort(*listenAddress)
if err != nil {
log.Fatalln(err)
}
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)
}
}

View File

@ -4,7 +4,7 @@ import (
"testing"
"time"
"git.arav.su/Arav/justcaptcha/pkg/dwcaptcha"
"git.arav.su/Arav/justcaptcha/v2/dwcaptcha"
)
const expiry = 10 * time.Minute

View File

@ -5,7 +5,7 @@ import (
"math/rand"
"time"
"git.arav.su/Arav/justcaptcha/pkg/captcha"
captcha "git.arav.su/Arav/justcaptcha/v2"
"github.com/fogleman/gg"
)

9
go.mod
View File

@ -1,11 +1,8 @@
module git.arav.su/Arav/justcaptcha
module git.arav.su/Arav/justcaptcha/v2
go 1.19
go 1.12
require (
github.com/fogleman/gg v1.3.0
github.com/julienschmidt/httprouter v1.3.0
)
require github.com/fogleman/gg v1.3.0
require (
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect

2
go.sum
View File

@ -2,8 +2,6 @@ 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/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=

View File

@ -1,39 +0,0 @@
[Unit]
Description=A simple CAPTCHA service for your website
After=network.target
[Service]
Type=simple
Restart=on-failure
DynamicUser=yes
ExecStart=/usr/bin/justcaptchad -expiry 10m -listen /var/run/justcaptcha/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

View File

@ -5,7 +5,7 @@ import (
"sync"
"time"
"git.arav.su/Arav/justcaptcha/pkg/captcha"
captcha "git.arav.su/Arav/justcaptcha/v2"
)
// InMemoryDB implementation that lives in a memory (map).

View File

@ -4,9 +4,9 @@ import (
"testing"
"time"
"git.arav.su/Arav/justcaptcha/pkg/captcha"
"git.arav.su/Arav/justcaptcha/pkg/captcha/inmemdb"
"git.arav.su/Arav/justcaptcha/pkg/dwcaptcha"
captcha "git.arav.su/Arav/justcaptcha/v2"
"git.arav.su/Arav/justcaptcha/v2/dwcaptcha"
"git.arav.su/Arav/justcaptcha/v2/inmemdb"
)
const expiry = 10 * time.Minute

View File

@ -1,78 +0,0 @@
package http
import (
"fmt"
"image/jpeg"
"net/http"
"time"
"git.arav.su/Arav/justcaptcha/pkg/captcha"
"git.arav.su/Arav/justcaptcha/pkg/captcha/inmemdb"
"git.arav.su/Arav/justcaptcha/pkg/dwcaptcha"
)
const errMsgWrongAnswer = "An answer provided was wrong"
const errMsgImageNotFound = "cannot get an image for a non-existing CAPTCHA"
type CaptchaHandlers struct {
expiry time.Duration
}
func NewCaptchaHandlers(expiry time.Duration) *CaptchaHandlers {
inmemdb.SetExpiry(expiry)
return &CaptchaHandlers{expiry: expiry}
}
func (h *CaptchaHandlers) New(w http.ResponseWriter, r *http.Request) {
dc := dwcaptcha.NewDwellingCaptcha(h.expiry)
_, id := inmemdb.New(r.RemoteAddr, dc)
w.WriteHeader(http.StatusCreated)
fmt.Fprint(w, id)
}
func (h *CaptchaHandlers) Image(w http.ResponseWriter, r *http.Request) {
captchaID := captcha.ID(getURLParam(r, "captcha"))
captchaStyle := r.URL.Query().Get("style")
captchaImage := inmemdb.Image(captchaID, captchaStyle)
if captchaImage == nil {
http.Error(w, errMsgImageNotFound, http.StatusNotFound)
return
}
w.Header().Add("Content-Disposition", "inline; filename=\""+string(captchaID)+"\"")
jpeg.Encode(w, *captchaImage, &jpeg.Options{Quality: 20})
}
func (h *CaptchaHandlers) Solve(w http.ResponseWriter, r *http.Request) {
captchaID := captcha.ID(getURLParam(r, "captcha"))
r.ParseForm()
answer := captcha.Answer(r.FormValue("answer"))
if ok := inmemdb.Solve(captchaID, answer); !ok {
http.Error(w, errMsgWrongAnswer, http.StatusForbidden)
return
}
w.WriteHeader(http.StatusAccepted)
}
func (h *CaptchaHandlers) IsSolved(w http.ResponseWriter, r *http.Request) {
captchaID := captcha.ID(getURLParam(r, "captcha"))
isJustRemove := r.URL.Query().Has("remove")
if isJustRemove {
inmemdb.Remove(captchaID)
w.WriteHeader(http.StatusNoContent)
return
}
if solved := inmemdb.IsSolved(captchaID); !solved {
http.Error(w, errMsgWrongAnswer, http.StatusForbidden)
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@ -1,76 +0,0 @@
package http
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
}