Merge remote-tracking branch 'origin/master' into feature/device-last-seen

This commit is contained in:
Aleksandr Soloshenko 2024-02-24 14:13:54 +07:00
commit 28244692d6
19 changed files with 416 additions and 35 deletions

View File

@ -1,7 +1,6 @@
name: docker-publish
on:
push:
pull_request:
jobs:
@ -14,10 +13,10 @@ jobs:
uses: actions/checkout@v4
# step 2: set up go
- name: Set up Go 1.20
- name: Set up Go 1.21
uses: actions/setup-go@v4
with:
go-version: ">=1.20"
go-version: "1.21"
# step 3: install dependencies
- name: Install all Go dependencies

52
.github/workflows/golangci-lint.yml vendored Normal file
View File

@ -0,0 +1,52 @@
name: golangci-lint
on:
pull_request:
permissions:
contents: read
# Optional: allow read access to pull request. Use with `only-new-issues` option.
# pull-requests: read
jobs:
golangci:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: "1.21"
cache: false
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
with:
# Require: The version of golangci-lint to use.
# When `install-mode` is `binary` (default) the value can be v1.2 or v1.2.3 or `latest` to use the latest version.
# When `install-mode` is `goinstall` the value can be v1.2.3, `latest`, or the hash of a commit.
version: latest
# Optional: working directory, useful for monorepos
# working-directory: somedir
# Optional: golangci-lint command line arguments.
#
# Note: By default, the `.golangci.yml` file should be at the root of the repository.
# The location of the configuration file can be changed by using `--config=`
# args: --timeout=30m --config=/my/path/.golangci.yml --issues-exit-code=0
args: --timeout=5m
# Optional: show only new issues if it's a pull request. The default value is `false`.
# only-new-issues: true
# Optional: if set to true, then all caching functionality will be completely disabled,
# takes precedence over all other caching options.
# skip-cache: true
# Optional: if set to true, then the action won't cache or restore ~/go/pkg.
# skip-pkg-cache: true
# Optional: if set to true, then the action won't cache or restore ~/.cache/go-build.
# skip-build-cache: true
# Optional: The mode to install golangci-lint. It can be 'binary' or 'goinstall'.
# install-mode: "goinstall"

View File

@ -26,8 +26,11 @@ db-upgrade-raw:
run:
go run cmd/$(project_name)/main.go
lint:
golangci-lint run ./...
test:
go test -cover ./...
go test -race -coverprofile=coverage.out -covermode=atomic ./...
build:
go build -o tmp/$(project_name) ./cmd/$(project_name)

View File

@ -9,7 +9,7 @@ Authorization: Basic {{localCredentials}}
{
"message": "{{$localDatetime iso8601}}",
"ttl": 600,
"validUntil": "2024-02-10T12:00:00+00:00",
"phoneNumbers": [
"{{phone}}"
],
@ -22,6 +22,20 @@ POST {{localUrl}}/message HTTP/1.1
Content-Type: application/json
Authorization: Basic {{localCredentials}}
{
"message": "{{$localDatetime iso8601}}",
"ttl": 86400,
"phoneNumbers": [
"{{phone}}"
],
"withDeliveryReport": true
}
###
POST {{localUrl}}/message HTTP/1.1
Content-Type: application/json
Authorization: Basic {{localCredentials}}
{
"message": "17wc9/ZRf1l84LHkEK3hgA==.aH1XrMHAeMyF4PeiavV3dk8o2fP0nSo92IqseLQfg14=",
"ttl": 600,

View File

@ -19,11 +19,11 @@ Authorization: Basic {{credentials}}
{
"message": "{{$localDatetime iso8601}}",
"ttl": 600,
"validUntil": "2024-02-10T12:00:00+07:00",
"phoneNumbers": [
"{{phone}}"
],
"simNumber": 1,
"simNumber": {{$randomInt 1 2}},
"withDeliveryReport": true
}

View File

@ -469,6 +469,11 @@
"minimum": 5,
"example": 86400
},
"validUntil": {
"description": "Время окончания жизни сообщения",
"type": "string",
"example": "2020-01-01T00:00:00Z"
},
"withDeliveryReport": {
"description": "Запрашивать отчет о доставке",
"type": "boolean",

View File

@ -74,6 +74,10 @@ definitions:
example: 86400
minimum: 5
type: integer
validUntil:
description: Время окончания жизни сообщения
example: "2020-01-01T00:00:00Z"
type: string
withDeliveryReport:
description: Запрашивать отчет о доставке
example: true

4
go.mod
View File

@ -8,7 +8,7 @@ require (
firebase.google.com/go/v4 v4.12.1
github.com/capcom6/go-infra-fx v0.0.0-20240104165405-d2c3993a9516
github.com/go-playground/validator/v10 v10.16.0
github.com/gofiber/fiber/v2 v2.51.0
github.com/gofiber/fiber/v2 v2.52.1
github.com/jaevor/go-nanoid v1.3.0
github.com/nyaruka/phonenumbers v1.3.0
go.uber.org/fx v1.20.1
@ -37,7 +37,7 @@ require (
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/s2a-go v0.1.7 // indirect
github.com/google/uuid v1.4.0 // indirect
github.com/google/uuid v1.5.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
github.com/googleapis/gax-go/v2 v2.12.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect

8
go.sum
View File

@ -82,8 +82,8 @@ github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrt
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/gofiber/contrib/fiberzap/v2 v2.1.2 h1:7Z1BqS1sYK9e9jTwqPcWx9qQt46PI8oeswgAp6YNZC4=
github.com/gofiber/contrib/fiberzap/v2 v2.1.2/go.mod h1:ulCCQOdDYABGsOQfbndASmCsCN86hsC96iKoOTNYfy8=
github.com/gofiber/fiber/v2 v2.51.0 h1:JNACcZy5e2tGApWB2QrRpenTWn0fq0hkFm6k0C86gKQ=
github.com/gofiber/fiber/v2 v2.51.0/go.mod h1:xaQRZQJGqnKOQnbQw+ltvku3/h8QxvNi8o6JiJ7Ll0U=
github.com/gofiber/fiber/v2 v2.52.1 h1:1RoU2NS+b98o1L77sdl5mboGPiW+0Ypsi5oLmcYlgHI=
github.com/gofiber/fiber/v2 v2.52.1/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
@ -123,8 +123,8 @@ github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas=

View File

@ -94,6 +94,9 @@ func (h *thirdPartyHandler) postMessage(user models.User, c *fiber.Ctx) error {
if err := h.BodyParserValidator(c, &req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
if err := req.Validate(); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
skipPhoneValidation := c.QueryBool("skipPhoneValidation", false)

View File

@ -8,6 +8,10 @@ import (
"go.uber.org/zap"
)
type Validatable interface {
Validate() error
}
type Handler struct {
Logger *zap.Logger
Validator *validator.Validate
@ -18,11 +22,7 @@ func (h *Handler) BodyParserValidator(c *fiber.Ctx, out any) error {
return fmt.Errorf("can't parse body: %w", err)
}
if h.Validator == nil {
return nil
}
return h.Validator.Struct(out)
return h.validateStruct(out)
}
func (h *Handler) QueryParserValidator(c *fiber.Ctx, out any) error {
@ -30,11 +30,7 @@ func (h *Handler) QueryParserValidator(c *fiber.Ctx, out any) error {
return fmt.Errorf("can't parse query: %w", err)
}
if h.Validator == nil {
return nil
}
return h.Validator.Struct(out)
return h.validateStruct(out)
}
func (h *Handler) ParamsParserValidator(c *fiber.Ctx, out any) error {
@ -42,9 +38,21 @@ func (h *Handler) ParamsParserValidator(c *fiber.Ctx, out any) error {
return fmt.Errorf("can't parse params: %w", err)
}
if h.Validator == nil {
return nil
return h.validateStruct(out)
}
func (h *Handler) validateStruct(out any) error {
if h.Validator != nil {
if err := h.Validator.Struct(out); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
}
return h.Validator.Struct(out)
if req, ok := out.(Validatable); ok {
if err := req.Validate(); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
}
return nil
}

View File

@ -0,0 +1,205 @@
package handlers
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
"go.uber.org/zap/zaptest"
)
type TestRequestBody struct {
Name string `json:"name" validate:"required"`
Age int `json:"age" validate:"required"`
}
type TestRequestBodyNoValidate struct {
Name string `json:"name" validate:"required"`
Age int `json:"age" validate:"required"`
}
func (t *TestRequestBody) Validate() error {
if t.Age < 18 {
return fmt.Errorf("must be at least 18 years old")
}
return nil
}
type TestQueryParams struct {
Page int `query:"page" validate:"required"`
}
type TestURLParams struct {
ID string `params:"id" validate:"required,uuid"`
}
func TestHandler_BodyParserValidator(t *testing.T) {
logger := zaptest.NewLogger(t)
validate := validator.New()
handler := &Handler{
Logger: logger,
Validator: validate,
}
app := fiber.New()
app.Post("/test", func(c *fiber.Ctx) error {
var body TestRequestBody
return handler.BodyParserValidator(c, &body)
})
app.Post("/test2", func(c *fiber.Ctx) error {
var body TestRequestBodyNoValidate
return handler.BodyParserValidator(c, &body)
})
tests := []struct {
description string
path string
payload any
expectedStatus int
}{
{
description: "Valid request body",
path: "/test",
payload: &TestRequestBody{Name: "John Doe", Age: 25},
expectedStatus: fiber.StatusOK,
},
{
description: "Invalid request body - missing name",
path: "/test",
payload: &TestRequestBody{Age: 25},
expectedStatus: fiber.StatusBadRequest,
},
{
description: "Invalid request body - age too low",
path: "/test",
payload: &TestRequestBody{Name: "John Doe", Age: 17},
expectedStatus: fiber.StatusBadRequest,
},
{
description: "Valid request body - no validation",
path: "/test2",
payload: &TestRequestBodyNoValidate{Name: "John Doe", Age: 17},
expectedStatus: fiber.StatusOK,
},
{
description: "No request body",
path: "/test",
payload: nil,
expectedStatus: fiber.StatusUnprocessableEntity,
},
}
for _, test := range tests {
t.Run(test.description, func(t *testing.T) {
var req *http.Request
if test.payload != nil {
bodyBytes, _ := json.Marshal(test.payload)
req = httptest.NewRequest("POST", test.path, bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest("POST", test.path, nil)
}
resp, _ := app.Test(req)
if test.expectedStatus != resp.StatusCode {
t.Errorf("Expected status code %d, got %d", test.expectedStatus, resp.StatusCode)
}
})
}
}
func TestHandler_QueryParserValidator(t *testing.T) {
type fields struct {
Logger *zap.Logger
Validator *validator.Validate
}
type args struct {
c *fiber.Ctx
out any
}
tests := []struct {
name string
fields fields
args args
wantErr bool
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := &Handler{
Logger: tt.fields.Logger,
Validator: tt.fields.Validator,
}
if err := h.QueryParserValidator(tt.args.c, tt.args.out); (err != nil) != tt.wantErr {
t.Errorf("Handler.QueryParserValidator() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestHandler_ParamsParserValidator(t *testing.T) {
type fields struct {
Logger *zap.Logger
Validator *validator.Validate
}
type args struct {
c *fiber.Ctx
out any
}
tests := []struct {
name string
fields fields
args args
wantErr bool
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := &Handler{
Logger: tt.fields.Logger,
Validator: tt.fields.Validator,
}
if err := h.ParamsParserValidator(tt.args.c, tt.args.out); (err != nil) != tt.wantErr {
t.Errorf("Handler.ParamsParserValidator() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestHandler_validateStruct(t *testing.T) {
type fields struct {
Logger *zap.Logger
Validator *validator.Validate
}
type args struct {
out any
}
tests := []struct {
name string
fields fields
args args
wantErr bool
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := &Handler{
Logger: tt.fields.Logger,
Validator: tt.fields.Validator,
}
if err := h.validateStruct(tt.args.out); (err != nil) != tt.wantErr {
t.Errorf("Handler.validateStruct() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

View File

@ -83,11 +83,12 @@ func (s *MessagesService) SelectPending(deviceID string) ([]smsgateway.Message,
result[i] = smsgateway.Message{
ID: v.ExtID,
Message: v.Message,
TTL: ttl,
SimNumber: v.SimNumber,
WithDeliveryReport: types.AsPointer[bool](v.WithDeliveryReport),
IsEncrypted: v.IsEncrypted,
PhoneNumbers: s.recipientsToDomain(v.Recipients),
TTL: ttl,
ValidUntil: v.ValidUntil,
}
}
@ -149,7 +150,7 @@ func (s *MessagesService) Enqeue(device models.Device, message smsgateway.Messag
}
}
var validUntil *time.Time = nil
var validUntil *time.Time = message.ValidUntil
if message.TTL != nil && *message.TTL > 0 {
validUntil = types.AsPointer(time.Now().Add(time.Duration(*message.TTL) * time.Second))
}

View File

@ -75,7 +75,7 @@ func (c *Client) doRequest(ctx context.Context, method, path string, headers map
return err
}
defer func() {
io.Copy(io.Discard, resp.Body)
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}()

View File

@ -26,12 +26,12 @@ func TestClient_Send(t *testing.T) {
if string(req) != `{"message":"","phoneNumbers":null}` {
w.WriteHeader(http.StatusBadRequest)
w.Write(req)
_, _ = w.Write(req)
return
}
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{}`))
_, _ = w.Write([]byte(`{}`))
}))
defer server.Close()

View File

@ -1,8 +1,9 @@
package smsgateway
import "time"
type ProcessState string
import (
"fmt"
"time"
)
const (
MessageStatePending ProcessState = "Pending" // В ожидании
@ -27,11 +28,21 @@ type Device struct {
type Message struct {
ID string `json:"id,omitempty" validate:"omitempty,max=36" example:"PyDmBQZZXYmyxMwED8Fzy"` // Идентификатор
Message string `json:"message" validate:"required,max=65535" example:"Hello World!"` // Текст сообщения
TTL *uint64 `json:"ttl,omitempty" validate:"omitempty,min=5" example:"86400"` // Время жизни сообщения в секундах
SimNumber *uint8 `json:"simNumber,omitempty" validate:"omitempty,max=3" example:"1"` // Номер сим-карты
WithDeliveryReport *bool `json:"withDeliveryReport,omitempty" example:"true"` // Запрашивать отчет о доставке
IsEncrypted bool `json:"isEncrypted,omitempty" example:"true"` // Зашифровано
PhoneNumbers []string `json:"phoneNumbers" validate:"required,min=1,max=100,dive,required,min=10,max=128" example:"79990001234"` // Получатели
TTL *uint64 `json:"ttl,omitempty" validate:"omitempty,min=5" example:"86400"` // Время жизни сообщения в секундах
ValidUntil *time.Time `json:"validUntil,omitempty" example:"2020-01-01T00:00:00Z"` // Время окончания жизни сообщения
}
func (m Message) Validate() error {
if m.TTL != nil && m.ValidUntil != nil {
return fmt.Errorf("%w: ttl and validUntil", ErrConflictFields)
}
return nil
}
// Состояние сообщения

7
pkg/smsgateway/types.go Normal file
View File

@ -0,0 +1,7 @@
package smsgateway
import "errors"
type ProcessState string
var ErrConflictFields = errors.New("conflict fields")

View File

@ -52,3 +52,7 @@ To avoid mobile operator restrictions, we introduced a delay feature in version
## Can I use long or non-standard phone numbers?
Yes, starting from [1.6.1](https://github.com/capcom6/android-sms-gateway/releases/tag/v1.6.1) of the app, our system allows the use of long or non-standard phone numbers, which may be common with M2M (machine-to-machine) SIM cards or other special cases. To bypass the standard phone number validation, simply add the query parameter `skipPhoneValidation=true` to your API request. Please note that with validation disabled, you are responsible for ensuring the correctness of the phone numbers. They should still follow the E.164 format, beginning with a '+' and containing only digits.
## What does the `RESULT_ERROR_LIMIT_EXCEEDED` error mean SMS?
The `RESULT_ERROR_LIMIT_EXCEEDED` error occurs when you've hit the sending limit imposed by your carrier or the Android operating system. This is a safeguard against spamming and typically happens if you try to send too many messages in a short period. To avoid this, try spacing out your messages or contact your carrier to inquire about their message sending limits. See also [How can I set up delays between sending messages?](#how-can-i-set-up-delays-between-sending-messages)

View File

@ -175,4 +175,69 @@ class Encryptor {
return crypto.pbkdf2Sync(passphrase, salt, iterations, keyLength, "sha1");
}
}
```
### Python
Based on [pycryptodome](https://pypi.org/project/pycryptodome/)
```python
from Crypto.Cipher import AES
from Crypto.Hash import SHA1
from Crypto.Protocol.KDF import PBKDF2
from Crypto.Random import get_random_bytes
from Crypto.Util.Padding import pad, unpad
class AESEncryptor(BaseEncryptor):
def encrypt(self, cleartext: str) -> str:
saltBytes = self._generate_salt()
key = self._generate_key(saltBytes, self.iterations)
cipher = AES.new(key, AES.MODE_CBC, iv=saltBytes)
encrypted_bytes = cipher.encrypt(pad(cleartext.encode(), AES.block_size))
salt = base64.b64encode(saltBytes).decode("utf-8")
encrypted = base64.b64encode(encrypted_bytes).decode("utf-8")
return f"$aes-256-cbc/pbkdf2-sha1$i={self.iterations}${salt}${encrypted}"
def decrypt(self, encrypted: str) -> str:
chunks = encrypted.split("$")
if len(chunks) < 5:
raise ValueError("Invalid encryption format")
if chunks[1] != "aes-256-cbc/pbkdf2-sha1":
raise ValueError("Unsupported algorithm")
params = self._parse_params(chunks[2])
if "i" not in params:
raise ValueError("Missing iteration count")
iterations = int(params["i"])
salt = base64.b64decode(chunks[-2])
encrypted_bytes = base64.b64decode(chunks[-1])
key = self._generate_key(salt, iterations)
cipher = AES.new(key, AES.MODE_CBC, iv=salt)
decrypted_bytes = unpad(cipher.decrypt(encrypted_bytes), AES.block_size)
return decrypted_bytes.decode("utf-8")
def _generate_salt(self) -> bytes:
return get_random_bytes(16)
def _generate_key(self, salt: bytes, iterations: int) -> bytes:
return PBKDF2(
self.passphrase,
salt,
count=iterations,
dkLen=32,
hmac_hash_module=SHA1,
)
def _parse_params(self, params: str) -> t.Dict[str, str]:
return {k: v for k, v in [p.split("=") for p in params.split(",")]}
```