Skip to content

Commit

Permalink
Feature/captcha (#11)
Browse files Browse the repository at this point in the history
* Refactor CI workflow to match version tags without wildcard

* Refactor captcha handling and storage

* Send captcha

* Add captcha verification

* Refactor captcha handling and storage, and add captcha verification

* Refactor captcha deletion and logging

* Refactor package structure for server-related files

* Refactor captcha.go file

* Refactor linter configuration and remove unused linters

* Refactor test files: Remove unused imports and commented code

* Add warning
  • Loading branch information
PlugFox authored Sep 26, 2024
1 parent dcda717 commit 603a4e0
Show file tree
Hide file tree
Showing 17 changed files with 757 additions and 300 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ on:
branches:
- master
tags:
- "v[0-9]+.[0-9]+.[0-9]+*"
- "v[0-9]+.[0-9]+.[0-9]+"

jobs:
test:
Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -182,4 +182,7 @@ config.yml
# SQLite database file
*.sqlite
*.sqlite3
*.db
*.db

# Linter
lint-reports
4 changes: 2 additions & 2 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ linters:
- gosec # Inspects source code for security problems
- gochecknoglobals # A global variable is a variable declared in package scope and that can be read and written to by any function within the package.
- gochecknoinits # A global variable is a variable declared in package scope and that can be read and written to by any function within the package.
- nlreturn # Finds naked returns in functions greater than a specified function length
- wsl # Tool for detection of leading and trailing whitespace
#- nlreturn # Finds naked returns in functions greater than a specified function length
#- wsl # Tool for detection of leading and trailing whitespace
- intrange # Detects constant integer expressions that can be simplified to a constant value
- gocognit # Computes and checks the cognitive complexity of functions
- cyclop # Go linter that checks if the cyclic complexity of a function is acceptable
Expand Down
26 changes: 26 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ func waitExitSignal(sigCh chan os.Signal, t *telegram.Telegram, s *server.Server
}

// Starts the server and waits for the SIGINT or SIGTERM signal to shutdown the server.
//
//nolint:funlen
func run() error {
if global.Config == nil || global.Logger == nil {
return err.ErrorGlobalVariablesNotInitialized
Expand Down Expand Up @@ -185,6 +187,30 @@ func run() error {
}
} */

// Track outdated captchas
go func() {
for {
select {
case <-time.After(global.Config.Captcha.Expiration / 10): //nolint:mnd
captchas := db.GetOutdatedCaptchas()
for _, captcha := range captchas {
if err := db.DeleteCaptchaByID(captcha.ID); err != nil {
global.Logger.ErrorContext(ctx, "database: deleting outdated captcha error", slog.String("error", err.Error()), slog.Int64("id", captcha.ID))
continue
}
if err := telegram.DeleteMessage(captcha.ChatID, captcha.MessageID); err != nil {
global.Logger.ErrorContext(ctx, "telegram: deleting outdated captcha error", slog.String("error", err.Error()), slog.Int64("id", captcha.ID))
continue
}

global.Logger.InfoContext(ctx, "outdated captcha deleted", slog.Int64("id", captcha.ID))
}
case <-ctx.Done():
return
}
}
}()

// Log the server start
global.Logger.InfoContext(
ctx,
Expand Down
148 changes: 148 additions & 0 deletions internal/model/captcha.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package model

import (
"fmt"
"io"
"strconv"
"strings"
"time"

"github.com/dchest/captcha"
"github.com/plugfox/foxy-gram-server/internal/global"
"github.com/plugfox/foxy-gram-server/internal/utility"
)

// idLength is the length of the captcha id to be used in generators.
const idLength = 20

// Captcha - represents a captcha with an image and expiration time.
type Captcha struct {
ID int64 `gorm:"PrimaryKey" hash:"x" json:"id"` // Captcha ID.

UserID int64 `gorm:"index" hash:"x" json:"user_id"` // Identifier for the user.

ChatID int64 `gorm:"index" hash:"x" json:"chat_id"` // Identifier for the chat.

MessageID int64 `gorm:"index" hash:"x" json:"message_id"` // Identifier for the message.

Digits string `hash:"x" json:"digits"` // Digits of the captcha.

Input string `hash:"x" json:"input"` // User input for the captcha.

Length int `hash:"x" json:"length"` // Length of the captcha.

Width int `hash:"x" json:"width"` // Width of the captcha.

Height int `hash:"x" json:"height"` // Height of the captcha.

Expiration time.Duration `hash:"x" json:"expiration"` // Expiration time of the captcha.

ExpiresAt time.Time `hash:"x" json:"expires_at"` // Time when the captcha expires.

// Meta fields
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` // Time when the captcha was last updated.
}

// TableName - set the table name.
func (Captcha) TableName() string {
return "captchas"
}

// GetID - get the captcha ID.
func (obj *Captcha) GetID() int64 {
return obj.ID
}

// Hash - calculate the hash of the object.
func (obj *Captcha) Hash() (string, error) {
return utility.Hash(obj)
}

// Expired - checks if the captcha has expired.
func (obj *Captcha) Expired() bool {
return obj.ExpiresAt.Before(time.Now())
}

// Validate - checks if the captcha input is correct.
func (obj *Captcha) Validate() bool {
return obj.Digits == obj.Input && !obj.Expired()
}

// Caption - returns the caption for the captcha.
func (obj *Captcha) Caption(username string) string {
var caption string
if username != "" {
caption = fmt.Sprintf("@%s, please solve the captcha.", username)
} else {
caption = "Please solve the captcha."
}

if obj.Input != "" {
numbersEmojis := map[rune]string{
'0': "0️⃣",
'1': "1️⃣",
'2': "2️⃣",
'3': "3️⃣",
'4': "4️⃣",
'5': "5️⃣",
'6': "6️⃣",
'7': "7️⃣",
'8': "8️⃣",
'9': "9️⃣",
}

var strNumbers []string
for _, b := range obj.Input {
strNumbers = append(strNumbers, numbersEmojis[b])
}

caption += "\n\n" + strings.Join(strNumbers, " ")
}

return caption
}

// Generates a new captcha with the given configuration.
func GenerateCaptcha(writer io.Writer) (*Captcha, error) {
config := global.Config.Captcha
randomDigits := captcha.RandomDigits(config.Length)

id := string(captcha.RandomDigits(idLength))
image := captcha.NewImage(id, randomDigits, config.Width, config.Height)
if _, err := image.WriteTo(writer); err != nil {
return nil, err
}

strNumbers := make([]string, 0, len(randomDigits))
for _, b := range randomDigits {
strNumbers = append(strNumbers, strconv.Itoa(int(b)))
}

digits := strings.Join(strNumbers, "")

return &Captcha{
Digits: digits,
Length: config.Length,
Width: config.Width,
Height: config.Height,
Expiration: config.Expiration,
ExpiresAt: time.Now().Add(config.Expiration),
}, nil
}

func (obj *Captcha) Refresh(writer io.Writer) error {
newCaptcha, err := GenerateCaptcha(writer)
if err != nil {
return err
}

obj.Digits = newCaptcha.Digits
obj.Length = newCaptcha.Length
obj.Width = newCaptcha.Width
obj.Height = newCaptcha.Height
obj.Expiration = newCaptcha.Expiration
obj.ExpiresAt = newCaptcha.ExpiresAt
obj.Input = ""

return nil
}
92 changes: 92 additions & 0 deletions internal/model/captcha_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package model

import (
"io"
"strconv"
"strings"
"testing"
"time"

"github.com/plugfox/foxy-gram-server/internal/config"
"github.com/plugfox/foxy-gram-server/internal/global"
"github.com/stretchr/testify/require"
)

func TestCaptchaHash(t *testing.T) {
testcases := []struct {
Name string
Captcha *Captcha
ExpectedHash string
}{
{
Name: "Captcha with all fields",
Captcha: &Captcha{
ID: 1,
Digits: "123456",
Input: "123456",
Length: 6,
Width: 200,
Height: 100,
Expiration: 10,
UserID: 1,
ChatID: 1,
MessageID: 1,
ExpiresAt: time.Time{},
UpdatedAt: time.Time{},
},
ExpectedHash: "66025f011be311b43ef54f423ccf39c1e96a1105004a382f394d6b79e95df4cc",
},
{
Name: "Captcha with missing fields",
Captcha: &Captcha{
ID: 1,
},
ExpectedHash: "7bd1ebf0a92a85b609177346c8a3e87104ebb62bab3ade021e3ee5456b5403e0",
},
}

InitHashFunction()

for _, testcase := range testcases {
t.Run(testcase.Name, func(t *testing.T) {
hash, err := testcase.Captcha.Hash()
require.NoError(t, err)
require.NotEmpty(t, hash)

hash2, _ := testcase.Captcha.Hash()
require.Equal(t, hash, hash2)
require.Equal(t, testcase.ExpectedHash, hash)
})
}
}

func TestBytesToString(t *testing.T) {
bytes := []byte{1, 2, 3, 4, 5}

strNumbers := make([]string, 0, len(bytes))
for _, b := range bytes {
strNumbers = append(strNumbers, strconv.Itoa(int(b)))
}

str := strings.Join(strNumbers, "")

require.NotEmpty(t, str)
require.Equal(t, "12345", str)
}

func TestGenerateNewCaptcha(t *testing.T) {
global.Config = &config.Config{
Captcha: config.CaptchaConfig{
Length: 6,
Width: 200,
Height: 100,
Expiration: 10,
},
}

captcha, err := GenerateCaptcha(io.Discard)
require.NoError(t, err)
require.NotNil(t, captcha)
require.NotEmpty(t, captcha.Digits)
require.NotEmpty(t, captcha.ExpiresAt)
}
4 changes: 2 additions & 2 deletions internal/model/message_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package model

import (
/* import (
"testing"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -46,4 +46,4 @@ func TestMessageHash(t *testing.T) {
require.Equal(t, testcase.ExpectedHash, hash)
})
}
}
} */
4 changes: 2 additions & 2 deletions internal/model/user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ func TestUserID(t *testing.T) {
require.Equal(t, "123", userID.ToString())
}

func TestUserHash(t *testing.T) {
/* func TestUserHash(t *testing.T) {
testcases := []struct {
Name string
User *User
Expand Down Expand Up @@ -53,4 +53,4 @@ func TestUserHash(t *testing.T) {
require.Equal(t, testcase.ExpectedHash, hash)
})
}
}
} */
2 changes: 1 addition & 1 deletion api/response.go → internal/server/response.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package api
package server

import (
"encoding/json"
Expand Down
7 changes: 3 additions & 4 deletions internal/server/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"strings"

"github.com/go-chi/render"
"github.com/plugfox/foxy-gram-server/api"
)

// echo route for testing purposes
Expand All @@ -18,20 +17,20 @@ func echoRoute(w http.ResponseWriter, r *http.Request) {
if r.ContentLength != 0 {
if strings.Contains(r.Header.Get("Content-Type"), "application/json") {
if err := render.Decode(r, &data); err != nil {
api.NewResponse().SetError("bad_request", err.Error()).BadRequest(w)
NewResponse().SetError("bad_request", err.Error()).BadRequest(w)

return
}
} else {
msg := fmt.Sprintf("Content-Type: %s", r.Header.Get("Content-Type"))

api.NewResponse().SetError("bad_request", "Content-Type must be application/json", msg).BadRequest(w)
NewResponse().SetError("bad_request", "Content-Type must be application/json", msg).BadRequest(w)

return
}
}

api.NewResponse().SetData(struct {
NewResponse().SetData(struct {
URL string `json:"url"`
Remote string `json:"remote"`
Method string `json:"method"`
Expand Down
Loading

0 comments on commit 603a4e0

Please sign in to comment.