Initial commit with a fully working program, lel.

This commit is contained in:
Alexander Andreev 2022-06-24 23:09:46 +04:00
commit 21b2f24986
Signed by: Arav
GPG Key ID: 0388CC8FAA51063F
16 changed files with 702 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
bin/*
!bin/.keep
.vscode
*.test.yaml

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
The MIT License
Copyright (c) 2022 Alexander "Arav" Andreev <me@arav.top>
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.

34
Makefile Executable file
View File

@ -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

49
README.md Normal file
View File

@ -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

0
bin/.keep Normal file
View File

30
build/archlinux/PKGBUILD Normal file
View File

@ -0,0 +1,30 @@
# Maintainer: Alexander "Arav" Andreev <me@arav.top>
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
}

79
cmd/justcaptchad/main.go Normal file
View File

@ -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 <me@arav.top>")
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)
}
}

14
go.mod Normal file
View File

@ -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
)

12
go.sum Normal file
View File

@ -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=

View File

@ -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

View File

@ -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)
}

104
internal/captcha/db.go Normal file
View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}
}

76
pkg/server/http.go Normal file
View File

@ -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
}