diff --git a/README.md b/README.md index 9962396..a671ea0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,12 @@ +[![Contributors][contributors-shield]][contributors-url] +[![Forks][forks-shield]][forks-url] +[![Stargazers][stars-shield]][stars-url] +[![Issues][issues-shield]][issues-url] +[![Apache 2.0 License][license-shield]][license-url] + # Android SMS Gateway Server -This server acts as the backend component of the Android SMS Gateway, facilitating the sending of SMS messages through connected Android devices. It includes a RESTful API for message management, integration with Firebase Cloud Messaging (FCM), and a database for persistent storage. +This server acts as the backend component of the [Android SMS Gateway](https://github.com/capcom6/android-sms-gateway), facilitating the sending of SMS messages through connected Android devices. It includes a RESTful API for message management, integration with Firebase Cloud Messaging (FCM), and a database for persistent storage. ## Table of Contents @@ -8,18 +14,17 @@ This server acts as the backend component of the Android SMS Gateway, facilitati - [Table of Contents](#table-of-contents) - [Features](#features) - [Prerequisites](#prerequisites) - - [Installation](#installation) - - [Configuration](#configuration) - - [Running the Server](#running-the-server) - - [Running with Docker](#running-with-docker) + - [Quickstart](#quickstart) + - [Work modes](#work-modes) - [Contributing](#contributing) - [License](#license) ## Features - Send SMS messages via a RESTful API. -- Schedule and perform periodic tasks. -- Integrate with Firebase Cloud Messaging for notifications. +- Get message status. +- Get the list of connected devices. +- Public and private modes. ## Prerequisites @@ -27,63 +32,30 @@ This server acts as the backend component of the Android SMS Gateway, facilitati - Docker and Docker Compose (for Docker-based setup) - A configured MySQL/MariaDB database -## Installation - -To set up the server on your local machine for development and testing purposes, follow these steps: - -1. Clone the repository to your local machine. -2. Install Go (version 1.21 or newer) if not already installed. -3. Navigate to the cloned directory and install dependencies: - -```bash -make init -``` - -4. Build the server binary: - -```bash -make build -``` - -## Configuration +## Quickstart -The server uses `yaml` for configuration with ability to override some values from environment variables. By default configuration is loaded from the `config.yml` file in the root directory. But path can be overridden with the `CONFIG_PATH` environment variable. +The easiest way to get started with the server is to use the Docker-based setup in Private Mode. In this mode device registration endpoint is protected, so no one can register a new device without knowing the token. -Below is a template for the `config.yml` file with environment variables in comments: +1. Set up MySQL or MariaDB database. +2. Create config.yml, based on [config.example.yml](configs/config.example.yml). The most important sections are `database`, `http` and `gateway`. Environment variables can be used to override values in the config file. + 1. In `gateway.mode` section set `private`. + 2. In `gateway.private_token` section set the access token for device registration in private mode. This token must be set on devices with private mode active. +3. Start the server in Docker: `docker run -p 3000:3000 -v ./config.yml:/app/config.yml capcom6/sms-gateway:latest`. +4. Set up private mode on devices. +5. Use started private server with the same API as the public server at [sms.capcom.me](https://sms.capcom.me). -```yaml -http: - listen: ":3000" # HTTP__LISTEN -database: - dialect: "mysql" # DATABASE__DIALECT - host: "localhost" # DATABASE__HOST - port: 3306 # DATABASE__PORT - user: "sms" # DATABASE__USER - password: "sms" # DATABASE__PASSWORD - database: "sms" # DATABASE__DATABASE - timezone: "UTC" # DATABASE__TIMEZONE -fcm: - credentials_json: > - { - ... - } -tasks: - hashing: - interval_seconds: 900 -``` +See also [docker-composee.yml](deployments/docker-compose/docker-compose.yml) for Docker-based setup. -Replace the placeholder values with your actual configuration. +## Work modes -## Running the Server +The server has two work modes: public and private. The public mode allows anonymous device registration and used at [sms.capcom.me](https://sms.capcom.me). Private mode can be used to send sensitive messages and running server in local infrastructure. -### Running with Docker +In most operations public and private modes are the same. But there are some differences: -For convenience, a Docker-based setup is provided. Please refer to the Docker prerequisites above before proceeding. +- `POST /api/mobile/v1/device` endpoint is protected by API key in private mode. So it is not possible to register a new device on private server without knowing the token. +- FCM notifications from private server are sent through `sms.capcom.me`. Notifications don't contain any sensitive data like phone numbers or message text. -1. Prepare configuration file `config.yml` -2. Pull the Docker image: `docker pull capcom6/sms-gateway` -3. Apply database migrations: `docker run --rm -it -v ./config.yml:/app/config.yml capcom6/sms-gateway db:migrate` -4. Start the server: `docker run -p 3000:3000 -v ./config.yml:/app/config.yml capcom6/sms-gateway` +See also [private mode discussion](https://github.com/capcom6/android-sms-gateway/issues/20). ## Contributing @@ -100,4 +72,15 @@ Don't forget to give the project a star! Thanks again! ## License -Distributed under the Apache-2.0 license. See [LICENSE](LICENSE) for more information. \ No newline at end of file +Distributed under the Apache-2.0 license. See [LICENSE](LICENSE) for more information. + +[contributors-shield]: https://img.shields.io/github/contributors/capcom6/sms-gateway.svg?style=for-the-badge +[contributors-url]: https://github.com/capcom6/sms-gateway/graphs/contributors +[forks-shield]: https://img.shields.io/github/forks/capcom6/sms-gateway.svg?style=for-the-badge +[forks-url]: https://github.com/capcom6/sms-gateway/network/members +[stars-shield]: https://img.shields.io/github/stars/capcom6/sms-gateway.svg?style=for-the-badge +[stars-url]: https://github.com/capcom6/sms-gateway/stargazers +[issues-shield]: https://img.shields.io/github/issues/capcom6/sms-gateway.svg?style=for-the-badge +[issues-url]: https://github.com/capcom6/sms-gateway/issues +[license-shield]: https://img.shields.io/github/license/capcom6/sms-gateway.svg?style=for-the-badge +[license-url]: https://github.com/capcom6/sms-gateway/blob/master/LICENSE \ No newline at end of file diff --git a/api/requests.http b/api/requests.http index bbfbaff..e745028 100644 --- a/api/requests.http +++ b/api/requests.http @@ -5,6 +5,7 @@ ### POST {{baseUrl}}/api/mobile/v1/device HTTP/1.1 +Authorization: Bearer 123456789 Content-Type: application/json { @@ -19,7 +20,7 @@ Authorization: Basic {{credentials}} { "message": "{{$localDatetime iso8601}}", - "validUntil": "2024-02-10T12:00:00+07:00", + "ttl": 600, "phoneNumbers": [ "{{phone}}" ], @@ -72,3 +73,13 @@ Content-Type: application/json ] } ] + +### +POST {{baseUrl}}/api/upstream/v1/push HTTP/1.1 +Content-Type: application/json + +[ + { + "token": "eTxx88nfSla87gZuJcW5mS:APA91bHGxVgSqqRtxwFHD1q9em5Oa6xSP4gO_OZRrqOoP1wjf_7UMfXKsc4uws6rWkqn73jYCc1owyATB1v61mqak4ntpqtmRkNtTey7NQXa0Wz3uQZBWY-Ecbn2rWG2VJRihOzXRId-" + } +] \ No newline at end of file diff --git a/api/swagger.json b/api/swagger.json index 77855bf..d35be3e 100644 --- a/api/swagger.json +++ b/api/swagger.json @@ -1,18 +1,18 @@ { "schemes": [ - "http" + "https" ], "swagger": "2.0", "info": { - "description": "Авторизацию пользователя по логин-паролю", - "title": "SMS-шлюз - API сервера", + "description": "End-user authentication key", + "title": "SMS Gateway - API", "contact": { "name": "Aleksandr Soloshenko", - "email": "capcom@soft-c.ru" + "email": "i@capcom.me" }, "version": "1.0.0" }, - "host": "localhost:3000", + "host": "sms.capcom.me", "basePath": "/api", "paths": { "/3rdparty/v1/device": { @@ -22,18 +22,17 @@ "ApiAuth": [] } ], - "description": "Возвращает все устройства пользователя", + "description": "Returns list of registered devices", "produces": [ "application/json" ], "tags": [ - "Пользователь", - "Устройства" + "User" ], - "summary": "Получить устройства", + "summary": "List devices", "responses": { "200": { - "description": "Состояние сообщения", + "description": "Device list", "schema": { "type": "array", "items": { @@ -42,19 +41,19 @@ } }, "400": { - "description": "Некорректный запрос", + "description": "Invalid request", "schema": { "$ref": "#/definitions/smsgateway.ErrorResponse" } }, "401": { - "description": "Ошибка авторизации", + "description": "Unauthorized", "schema": { "$ref": "#/definitions/smsgateway.ErrorResponse" } }, "500": { - "description": "Внутренняя ошибка сервера", + "description": "Internal server error", "schema": { "$ref": "#/definitions/smsgateway.ErrorResponse" } @@ -69,19 +68,19 @@ "ApiAuth": [] } ], - "description": "Возвращает состояние сообщения по его ID", + "description": "Returns message state by ID", "produces": [ "application/json" ], "tags": [ - "Пользователь", - "Сообщения" + "User", + "Messages" ], - "summary": "Получить состояние сообщения", + "summary": "Get message state", "parameters": [ { "type": "string", - "description": "ИД сообщения", + "description": "Message ID", "name": "id", "in": "path", "required": true @@ -89,25 +88,25 @@ ], "responses": { "200": { - "description": "Состояние сообщения", + "description": "Message state", "schema": { "$ref": "#/definitions/smsgateway.MessageState" } }, "400": { - "description": "Некорректный запрос", + "description": "Invalid request", "schema": { "$ref": "#/definitions/smsgateway.ErrorResponse" } }, "401": { - "description": "Ошибка авторизации", + "description": "Unauthorized", "schema": { "$ref": "#/definitions/smsgateway.ErrorResponse" } }, "500": { - "description": "Внутренняя ошибка сервера", + "description": "Internal server error", "schema": { "$ref": "#/definitions/smsgateway.ErrorResponse" } @@ -120,7 +119,7 @@ "ApiAuth": [] } ], - "description": "Ставит сообщение в очередь на отправку. Если идентификатор не указан, то он будет сгенерирован автоматически", + "description": "Enqueues message for sending. If ID is not specified, it will be generated", "consumes": [ "application/json" ], @@ -128,19 +127,19 @@ "application/json" ], "tags": [ - "Пользователь", - "Сообщения" + "User", + "Messages" ], - "summary": "Поставить сообщение в очередь", + "summary": "Enqueue message", "parameters": [ { "type": "boolean", - "description": "Пропустить проверку номеров телефона", + "description": "Skip phone validation", "name": "skipPhoneValidation", "in": "query" }, { - "description": "Сообщение", + "description": "Send message request", "name": "request", "in": "body", "required": true, @@ -151,31 +150,31 @@ ], "responses": { "202": { - "description": "Сообщение поставлено в очередь", + "description": "Message enqueued", "schema": { "$ref": "#/definitions/smsgateway.MessageState" }, "headers": { "Location": { "type": "string", - "description": "URL для получения состояния сообщения" + "description": "Get message state URL" } } }, "400": { - "description": "Некорректный запрос", + "description": "Invalid request", "schema": { "$ref": "#/definitions/smsgateway.ErrorResponse" } }, "401": { - "description": "Ошибка авторизации", + "description": "Unauthorized", "schema": { "$ref": "#/definitions/smsgateway.ErrorResponse" } }, "500": { - "description": "Внутренняя ошибка сервера", + "description": "Internal server error", "schema": { "$ref": "#/definitions/smsgateway.ErrorResponse" } @@ -185,7 +184,7 @@ }, "/mobile/v1/device": { "post": { - "description": "Регистрирует устройство на сервере, генерируя авторизационные данные", + "description": "Registers new device and returns credentials", "consumes": [ "application/json" ], @@ -193,13 +192,12 @@ "application/json" ], "tags": [ - "Устройство", - "Регистрация" + "Device" ], - "summary": "Регистрация устройства", + "summary": "Register device", "parameters": [ { - "description": "Запрос на регистрацию", + "description": "Device registration request", "name": "request", "in": "body", "required": true, @@ -210,19 +208,31 @@ ], "responses": { "201": { - "description": "Успешная регистрация", + "description": "Device registered", "schema": { "$ref": "#/definitions/smsgateway.MobileRegisterResponse" } }, "400": { - "description": "Некорректный запрос", + "description": "Invalid request", + "schema": { + "$ref": "#/definitions/smsgateway.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized (private mode only)", + "schema": { + "$ref": "#/definitions/smsgateway.ErrorResponse" + } + }, + "429": { + "description": "Too many requests", "schema": { "$ref": "#/definitions/smsgateway.ErrorResponse" } }, "500": { - "description": "Внутренняя ошибка сервера", + "description": "Internal server error", "schema": { "$ref": "#/definitions/smsgateway.ErrorResponse" } @@ -235,17 +245,17 @@ "MobileToken": [] } ], - "description": "Обновляет push-токен устройства", + "description": "Updates push token for device", "consumes": [ "application/json" ], "tags": [ - "Устройство" + "Device" ], - "summary": "Обновление устройства", + "summary": "Update device", "parameters": [ { - "description": "Запрос на обновление", + "description": "Device update request", "name": "request", "in": "body", "required": true, @@ -256,22 +266,22 @@ ], "responses": { "204": { - "description": "Успешное обновление" + "description": "Successfully updated" }, "400": { - "description": "Некорректный запрос", + "description": "Invalid request", "schema": { "$ref": "#/definitions/smsgateway.ErrorResponse" } }, "403": { - "description": "Операция запрещена", + "description": "Forbidden (wrong device ID)", "schema": { "$ref": "#/definitions/smsgateway.ErrorResponse" } }, "500": { - "description": "Внутренняя ошибка сервера", + "description": "Internal server error", "schema": { "$ref": "#/definitions/smsgateway.ErrorResponse" } @@ -286,7 +296,7 @@ "MobileToken": [] } ], - "description": "Возвращает список сообщений, требующих отправки", + "description": "Returns list of pending messages", "consumes": [ "application/json" ], @@ -294,13 +304,13 @@ "application/json" ], "tags": [ - "Устройство", - "Сообщения" + "Device", + "Messages" ], - "summary": "Получить сообщения для отправки", + "summary": "Get messages for sending", "responses": { "200": { - "description": "Список сообщений", + "description": "List of pending messages", "schema": { "type": "array", "items": { @@ -309,7 +319,7 @@ } }, "500": { - "description": "Внутренняя ошибка сервера", + "description": "Internal server error", "schema": { "$ref": "#/definitions/smsgateway.ErrorResponse" } @@ -322,7 +332,7 @@ "MobileToken": [] } ], - "description": "Обновляет состояние сообщений. Состояние обновляется индивидуально для каждого сообщения, игнорируя ошибки", + "description": "Updates message state", "consumes": [ "application/json" ], @@ -330,13 +340,13 @@ "application/json" ], "tags": [ - "Устройство", - "Сообщения" + "Device", + "Messages" ], - "summary": "Обновить состояние сообщений", + "summary": "Update message state", "parameters": [ { - "description": "Состояние сообщений", + "description": "New message state", "name": "request", "in": "body", "required": true, @@ -350,16 +360,69 @@ ], "responses": { "204": { - "description": "Обновление выполнено" + "description": "Successfully updated" + }, + "400": { + "description": "Invalid request", + "schema": { + "$ref": "#/definitions/smsgateway.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/smsgateway.ErrorResponse" + } + } + } + } + }, + "/upstream/v1/push": { + "post": { + "description": "Enqueues notifications for sending to devices", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Device", + "Upstream" + ], + "summary": "Send push notifications", + "parameters": [ + { + "description": "Push request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/smsgateway.PushNotification" + } + } + } + ], + "responses": { + "202": { + "description": "Notification enqueued" }, "400": { - "description": "Некорректный запрос", + "description": "Invalid request", + "schema": { + "$ref": "#/definitions/smsgateway.ErrorResponse" + } + }, + "429": { + "description": "Too many requests", "schema": { "$ref": "#/definitions/smsgateway.ErrorResponse" } }, "500": { - "description": "Внутренняя ошибка сервера", + "description": "Internal server error", "schema": { "$ref": "#/definitions/smsgateway.ErrorResponse" } @@ -373,32 +436,32 @@ "type": "object", "properties": { "createdAt": { - "description": "Дата создания", + "description": "Created at (read only)", "type": "string", "example": "2020-01-01T00:00:00Z" }, "deletedAt": { - "description": "Дата удаления", + "description": "Deleted at (read only)", "type": "string", "example": "2020-01-01T00:00:00Z" }, "id": { - "description": "Идентификатор", + "description": "ID", "type": "string", "example": "PyDmBQZZXYmyxMwED8Fzy" }, "lastSeen": { - "description": "Последняя активность", + "description": "Last seen at (read only)", "type": "string", "example": "2020-01-01T00:00:00Z" }, "name": { - "description": "Название устройства", + "description": "Name", "type": "string", "example": "My Device" }, "updatedAt": { - "description": "Дата обновления", + "description": "Updated at (read only)", "type": "string", "example": "2020-01-01T00:00:00Z" } @@ -408,16 +471,16 @@ "type": "object", "properties": { "code": { - "description": "код ошибки", + "description": "Error code", "type": "integer" }, "data": { - "description": "контекст" + "description": "Error context" }, "message": { - "description": "текст ошибки", + "description": "Error message", "type": "string", - "example": "Произошла ошибка" + "example": "An error occurred" } } }, @@ -429,24 +492,24 @@ ], "properties": { "id": { - "description": "Идентификатор", + "description": "ID (if not set - will be generated)", "type": "string", "maxLength": 36, "example": "PyDmBQZZXYmyxMwED8Fzy" }, "isEncrypted": { - "description": "Зашифровано", + "description": "Is encrypted", "type": "boolean", "example": true }, "message": { - "description": "Текст сообщения", + "description": "Content", "type": "string", "maxLength": 65535, "example": "Hello World!" }, "phoneNumbers": { - "description": "Получатели", + "description": "Recipients (phone numbers)", "type": "array", "maxItems": 100, "minItems": 1, @@ -458,24 +521,24 @@ ] }, "simNumber": { - "description": "Номер сим-карты", + "description": "SIM card number (1-3)", "type": "integer", "maximum": 3, "example": 1 }, "ttl": { - "description": "Время жизни сообщения в секундах", + "description": "Time to live in seconds (conflicts with `validUntil`)", "type": "integer", "minimum": 5, "example": 86400 }, "validUntil": { - "description": "Время окончания жизни сообщения", + "description": "Valid until (conflicts with `ttl`)", "type": "string", "example": "2020-01-01T00:00:00Z" }, "withDeliveryReport": { - "description": "Запрашивать отчет о доставке", + "description": "With delivery report", "type": "boolean", "example": true } @@ -489,23 +552,23 @@ ], "properties": { "id": { - "description": "Идентификатор", + "description": "Message ID", "type": "string", "maxLength": 36, "example": "PyDmBQZZXYmyxMwED8Fzy" }, "isEncrypted": { - "description": "Зашифровано", + "description": "Encrypted", "type": "boolean", "example": false }, "isHashed": { - "description": "Хэшировано", + "description": "Hashed", "type": "boolean", "example": false }, "recipients": { - "description": "Детализация состояния по получателям", + "description": "Recipients states", "type": "array", "minItems": 1, "items": { @@ -513,7 +576,7 @@ } }, "state": { - "description": "Состояние", + "description": "State", "allOf": [ { "$ref": "#/definitions/smsgateway.ProcessState" @@ -527,13 +590,13 @@ "type": "object", "properties": { "name": { - "description": "Имя устройства", + "description": "Device name", "type": "string", "maxLength": 128, "example": "Android Phone" }, "pushToken": { - "description": "Токен для отправки PUSH-уведомлений", + "description": "FCM token", "type": "string", "maxLength": 256, "example": "gHz-T6NezDlOfllr7F-Be" @@ -544,22 +607,22 @@ "type": "object", "properties": { "id": { - "description": "Идентификатор", + "description": "New device ID", "type": "string", "example": "QslD_GefqiYV6RQXdkM6V" }, "login": { - "description": "Логин пользователя", + "description": "User login", "type": "string", "example": "VQ4GII" }, "password": { - "description": "Пароль пользователя", + "description": "User password", "type": "string", "example": "cp2pydvxd2zwpx" }, "token": { - "description": "Ключ доступа", + "description": "Device access token", "type": "string", "example": "bP0ZdK6rC6hCYZSjzmqhQ" } @@ -569,12 +632,12 @@ "type": "object", "properties": { "id": { - "description": "Идентификатор, если есть", + "description": "ID", "type": "string", "example": "QslD_GefqiYV6RQXdkM6V" }, "pushToken": { - "description": "Токен для отправки PUSH-уведомлений", + "description": "FCM token", "type": "string", "maxLength": 256, "example": "gHz-T6NezDlOfllr7F-Be" @@ -591,11 +654,11 @@ "Failed" ], "x-enum-comments": { - "MessageStateDelivered": "Доставлено", - "MessageStateFailed": "Ошибка", - "MessageStatePending": "В ожидании", - "MessageStateProcessed": "Обработано", - "MessageStateSent": "Отправлено" + "MessageStateDelivered": "Delivered", + "MessageStateFailed": "Failed", + "MessageStatePending": "Pending", + "MessageStateProcessed": "Processed (received by device)", + "MessageStateSent": "Sent" }, "x-enum-varnames": [ "MessageStatePending", @@ -605,6 +668,19 @@ "MessageStateFailed" ] }, + "smsgateway.PushNotification": { + "type": "object", + "required": [ + "token" + ], + "properties": { + "token": { + "description": "Device FCM token", + "type": "string", + "example": "PyDmBQZZXYmyxMwED8Fzy" + } + } + }, "smsgateway.RecipientState": { "type": "object", "required": [ @@ -613,19 +689,19 @@ ], "properties": { "error": { - "description": "Ошибка", + "description": "Error (for `Failed` state)", "type": "string", "example": "timeout" }, "phoneNumber": { - "description": "Номер телефона или первые 16 символов SHA256", + "description": "Phone number or first 16 symbols of SHA256 hash", "type": "string", "maxLength": 128, "minLength": 10, "example": "79990001234" }, "state": { - "description": "Состояние", + "description": "State", "allOf": [ { "$ref": "#/definitions/smsgateway.ProcessState" @@ -641,7 +717,7 @@ "type": "basic" }, "MobileToken": { - "description": "Авторизацию устройства по токену", + "description": "Mobile device token", "type": "apiKey", "name": "Authorization", "in": "header" diff --git a/api/swagger.yaml b/api/swagger.yaml index 0df2d66..4933712 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -3,60 +3,60 @@ definitions: smsgateway.Device: properties: createdAt: - description: Дата создания + description: Created at (read only) example: "2020-01-01T00:00:00Z" type: string deletedAt: - description: Дата удаления + description: Deleted at (read only) example: "2020-01-01T00:00:00Z" type: string id: - description: Идентификатор + description: ID example: PyDmBQZZXYmyxMwED8Fzy type: string lastSeen: - description: Последняя активность + description: Last seen at (read only) example: "2020-01-01T00:00:00Z" type: string name: - description: Название устройства + description: Name example: My Device type: string updatedAt: - description: Дата обновления + description: Updated at (read only) example: "2020-01-01T00:00:00Z" type: string type: object smsgateway.ErrorResponse: properties: code: - description: код ошибки + description: Error code type: integer data: - description: контекст + description: Error context message: - description: текст ошибки - example: Произошла ошибка + description: Error message + example: An error occurred type: string type: object smsgateway.Message: properties: id: - description: Идентификатор + description: ID (if not set - will be generated) example: PyDmBQZZXYmyxMwED8Fzy maxLength: 36 type: string isEncrypted: - description: Зашифровано + description: Is encrypted example: true type: boolean message: - description: Текст сообщения + description: Content example: Hello World! maxLength: 65535 type: string phoneNumbers: - description: Получатели + description: Recipients (phone numbers) example: - "79990001234" items: @@ -65,21 +65,21 @@ definitions: minItems: 1 type: array simNumber: - description: Номер сим-карты + description: SIM card number (1-3) example: 1 maximum: 3 type: integer ttl: - description: Время жизни сообщения в секундах + description: Time to live in seconds (conflicts with `validUntil`) example: 86400 minimum: 5 type: integer validUntil: - description: Время окончания жизни сообщения + description: Valid until (conflicts with `ttl`) example: "2020-01-01T00:00:00Z" type: string withDeliveryReport: - description: Запрашивать отчет о доставке + description: With delivery report example: true type: boolean required: @@ -89,20 +89,20 @@ definitions: smsgateway.MessageState: properties: id: - description: Идентификатор + description: Message ID example: PyDmBQZZXYmyxMwED8Fzy maxLength: 36 type: string isEncrypted: - description: Зашифровано + description: Encrypted example: false type: boolean isHashed: - description: Хэшировано + description: Hashed example: false type: boolean recipients: - description: Детализация состояния по получателям + description: Recipients states items: $ref: '#/definitions/smsgateway.RecipientState' minItems: 1 @@ -110,7 +110,7 @@ definitions: state: allOf: - $ref: '#/definitions/smsgateway.ProcessState' - description: Состояние + description: State example: Pending required: - recipients @@ -119,12 +119,12 @@ definitions: smsgateway.MobileRegisterRequest: properties: name: - description: Имя устройства + description: Device name example: Android Phone maxLength: 128 type: string pushToken: - description: Токен для отправки PUSH-уведомлений + description: FCM token example: gHz-T6NezDlOfllr7F-Be maxLength: 256 type: string @@ -132,30 +132,30 @@ definitions: smsgateway.MobileRegisterResponse: properties: id: - description: Идентификатор + description: New device ID example: QslD_GefqiYV6RQXdkM6V type: string login: - description: Логин пользователя + description: User login example: VQ4GII type: string password: - description: Пароль пользователя + description: User password example: cp2pydvxd2zwpx type: string token: - description: Ключ доступа + description: Device access token example: bP0ZdK6rC6hCYZSjzmqhQ type: string type: object smsgateway.MobileUpdateRequest: properties: id: - description: Идентификатор, если есть + description: ID example: QslD_GefqiYV6RQXdkM6V type: string pushToken: - description: Токен для отправки PUSH-уведомлений + description: FCM token example: gHz-T6NezDlOfllr7F-Be maxLength: 256 type: string @@ -169,25 +169,34 @@ definitions: - Failed type: string x-enum-comments: - MessageStateDelivered: Доставлено - MessageStateFailed: Ошибка - MessageStatePending: В ожидании - MessageStateProcessed: Обработано - MessageStateSent: Отправлено + MessageStateDelivered: Delivered + MessageStateFailed: Failed + MessageStatePending: Pending + MessageStateProcessed: Processed (received by device) + MessageStateSent: Sent x-enum-varnames: - MessageStatePending - MessageStateProcessed - MessageStateSent - MessageStateDelivered - MessageStateFailed + smsgateway.PushNotification: + properties: + token: + description: Device FCM token + example: PyDmBQZZXYmyxMwED8Fzy + type: string + required: + - token + type: object smsgateway.RecipientState: properties: error: - description: Ошибка + description: Error (for `Failed` state) example: timeout type: string phoneNumber: - description: Номер телефона или первые 16 символов SHA256 + description: Phone number or first 16 symbols of SHA256 hash example: "79990001234" maxLength: 128 minLength: 10 @@ -195,56 +204,55 @@ definitions: state: allOf: - $ref: '#/definitions/smsgateway.ProcessState' - description: Состояние + description: State example: Pending required: - phoneNumber - state type: object -host: localhost:3000 +host: sms.capcom.me info: contact: - email: capcom@soft-c.ru + email: i@capcom.me name: Aleksandr Soloshenko - description: Авторизацию пользователя по логин-паролю - title: SMS-шлюз - API сервера + description: End-user authentication key + title: SMS Gateway - API version: 1.0.0 paths: /3rdparty/v1/device: get: - description: Возвращает все устройства пользователя + description: Returns list of registered devices produces: - application/json responses: "200": - description: Состояние сообщения + description: Device list schema: items: $ref: '#/definitions/smsgateway.Device' type: array "400": - description: Некорректный запрос + description: Invalid request schema: $ref: '#/definitions/smsgateway.ErrorResponse' "401": - description: Ошибка авторизации + description: Unauthorized schema: $ref: '#/definitions/smsgateway.ErrorResponse' "500": - description: Внутренняя ошибка сервера + description: Internal server error schema: $ref: '#/definitions/smsgateway.ErrorResponse' security: - ApiAuth: [] - summary: Получить устройства + summary: List devices tags: - - Пользователь - - Устройства + - User /3rdparty/v1/message: get: - description: Возвращает состояние сообщения по его ID + description: Returns message state by ID parameters: - - description: ИД сообщения + - description: Message ID in: path name: id required: true @@ -253,38 +261,38 @@ paths: - application/json responses: "200": - description: Состояние сообщения + description: Message state schema: $ref: '#/definitions/smsgateway.MessageState' "400": - description: Некорректный запрос + description: Invalid request schema: $ref: '#/definitions/smsgateway.ErrorResponse' "401": - description: Ошибка авторизации + description: Unauthorized schema: $ref: '#/definitions/smsgateway.ErrorResponse' "500": - description: Внутренняя ошибка сервера + description: Internal server error schema: $ref: '#/definitions/smsgateway.ErrorResponse' security: - ApiAuth: [] - summary: Получить состояние сообщения + summary: Get message state tags: - - Пользователь - - Сообщения + - User + - Messages post: consumes: - application/json - description: Ставит сообщение в очередь на отправку. Если идентификатор не указан, - то он будет сгенерирован автоматически + description: Enqueues message for sending. If ID is not specified, it will be + generated parameters: - - description: Пропустить проверку номеров телефона + - description: Skip phone validation in: query name: skipPhoneValidation type: boolean - - description: Сообщение + - description: Send message request in: body name: request required: true @@ -294,38 +302,38 @@ paths: - application/json responses: "202": - description: Сообщение поставлено в очередь + description: Message enqueued headers: Location: - description: URL для получения состояния сообщения + description: Get message state URL type: string schema: $ref: '#/definitions/smsgateway.MessageState' "400": - description: Некорректный запрос + description: Invalid request schema: $ref: '#/definitions/smsgateway.ErrorResponse' "401": - description: Ошибка авторизации + description: Unauthorized schema: $ref: '#/definitions/smsgateway.ErrorResponse' "500": - description: Внутренняя ошибка сервера + description: Internal server error schema: $ref: '#/definitions/smsgateway.ErrorResponse' security: - ApiAuth: [] - summary: Поставить сообщение в очередь + summary: Enqueue message tags: - - Пользователь - - Сообщения + - User + - Messages /mobile/v1/device: patch: consumes: - application/json - description: Обновляет push-токен устройства + description: Updates push token for device parameters: - - description: Запрос на обновление + - description: Device update request in: body name: request required: true @@ -333,30 +341,30 @@ paths: $ref: '#/definitions/smsgateway.MobileUpdateRequest' responses: "204": - description: Успешное обновление + description: Successfully updated "400": - description: Некорректный запрос + description: Invalid request schema: $ref: '#/definitions/smsgateway.ErrorResponse' "403": - description: Операция запрещена + description: Forbidden (wrong device ID) schema: $ref: '#/definitions/smsgateway.ErrorResponse' "500": - description: Внутренняя ошибка сервера + description: Internal server error schema: $ref: '#/definitions/smsgateway.ErrorResponse' security: - MobileToken: [] - summary: Обновление устройства + summary: Update device tags: - - Устройство + - Device post: consumes: - application/json - description: Регистрирует устройство на сервере, генерируя авторизационные данные + description: Registers new device and returns credentials parameters: - - description: Запрос на регистрацию + - description: Device registration request in: body name: request required: true @@ -366,52 +374,58 @@ paths: - application/json responses: "201": - description: Успешная регистрация + description: Device registered schema: $ref: '#/definitions/smsgateway.MobileRegisterResponse' "400": - description: Некорректный запрос + description: Invalid request + schema: + $ref: '#/definitions/smsgateway.ErrorResponse' + "401": + description: Unauthorized (private mode only) + schema: + $ref: '#/definitions/smsgateway.ErrorResponse' + "429": + description: Too many requests schema: $ref: '#/definitions/smsgateway.ErrorResponse' "500": - description: Внутренняя ошибка сервера + description: Internal server error schema: $ref: '#/definitions/smsgateway.ErrorResponse' - summary: Регистрация устройства + summary: Register device tags: - - Устройство - - Регистрация + - Device /mobile/v1/message: get: consumes: - application/json - description: Возвращает список сообщений, требующих отправки + description: Returns list of pending messages produces: - application/json responses: "200": - description: Список сообщений + description: List of pending messages schema: items: $ref: '#/definitions/smsgateway.Message' type: array "500": - description: Внутренняя ошибка сервера + description: Internal server error schema: $ref: '#/definitions/smsgateway.ErrorResponse' security: - MobileToken: [] - summary: Получить сообщения для отправки + summary: Get messages for sending tags: - - Устройство - - Сообщения + - Device + - Messages patch: consumes: - application/json - description: Обновляет состояние сообщений. Состояние обновляется индивидуально - для каждого сообщения, игнорируя ошибки + description: Updates message state parameters: - - description: Состояние сообщений + - description: New message state in: body name: request required: true @@ -423,28 +437,63 @@ paths: - application/json responses: "204": - description: Обновление выполнено + description: Successfully updated "400": - description: Некорректный запрос + description: Invalid request schema: $ref: '#/definitions/smsgateway.ErrorResponse' "500": - description: Внутренняя ошибка сервера + description: Internal server error schema: $ref: '#/definitions/smsgateway.ErrorResponse' security: - MobileToken: [] - summary: Обновить состояние сообщений + summary: Update message state + tags: + - Device + - Messages + /upstream/v1/push: + post: + consumes: + - application/json + description: Enqueues notifications for sending to devices + parameters: + - description: Push request + in: body + name: request + required: true + schema: + items: + $ref: '#/definitions/smsgateway.PushNotification' + type: array + produces: + - application/json + responses: + "202": + description: Notification enqueued + "400": + description: Invalid request + schema: + $ref: '#/definitions/smsgateway.ErrorResponse' + "429": + description: Too many requests + schema: + $ref: '#/definitions/smsgateway.ErrorResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/smsgateway.ErrorResponse' + summary: Send push notifications tags: - - Устройство - - Сообщения + - Device + - Upstream schemes: -- http +- https securityDefinitions: ApiAuth: type: basic MobileToken: - description: Авторизацию устройства по токену + description: Mobile device token in: header name: Authorization type: apiKey diff --git a/bitbucket-pipelines.yml b/bitbucket-pipelines.yml deleted file mode 100644 index 1e3882f..0000000 --- a/bitbucket-pipelines.yml +++ /dev/null @@ -1,81 +0,0 @@ -image: atlassian/default-image:3 - -definitions: - caches: - gomodules: ~/.cache/go-build - steps: - - step: &docker-lint - runs-on: - - self.hosted - - linux - name: Lint the Dockerfile - image: hadolint/hadolint:latest-debian - script: - - hadolint build/package/Dockerfile* - - step: &unit-tests - runs-on: - - self.hosted - - linux - name: Run unit tests - image: golang:1.20 - caches: - - gomodules - script: - - echo '[url "ssh://git@bitbucket.org/"]' >> ~/.gitconfig - - echo ' insteadOf = https://bitbucket.org/' >> ~/.gitconfig - - go env -w GOPRIVATE="bitbucket.org/soft-c/*" - - go test ./... - - step: &build-docker - runs-on: - - self.hosted - - linux - name: Build and push Docker image - services: - - docker - caches: - - docker - script: - - docker version - - echo ${DOCKER_PASSWORD} | docker login ${DOCKER_CR} --username "$DOCKER_USERNAME" --password-stdin - - export SSH_PRV_KEY="$(cat /opt/atlassian/pipelines/agent/ssh/id_rsa)" - - chmod +x ./scripts/docker-build.sh && ./scripts/docker-build.sh - - step: &deploy - runs-on: - - self.hosted - - linux - name: Deploy to Docker Swarm - image: hashicorp/terraform:1.4 - deployment: production - script: - - terraform -chdir=./deployments/docker-swarm-terraform init - - | - terraform -chdir=./deployments/docker-swarm-terraform apply -auto-approve -input=false \ - -var "swarm-manager-host=${SWARM_MANAGER_HOST}" \ - -var "registry-password=${DOCKER_PASSWORD}" \ - -var "app-name=${APP_NAME}" \ - -var "app-version=${BITBUCKET_TAG#v}" \ - -var "app-host=${APP_HOST}" \ - -var "app-config-b64=${APP_CONFIG_B64}" \ - -var "app-env-json-b64=${APP_ENV_JSON_B64}" - -pipelines: - default: - - parallel: - - step: *docker-lint - - step: *unit-tests - - step: *build-docker - branches: - testing: - - parallel: - - step: *docker-lint - - step: *unit-tests - - step: *build-docker - master: - - parallel: - - step: *docker-lint - - step: *unit-tests - - step: *build-docker - tags: - v*: - - step: *build-docker - - step: *deploy diff --git a/cmd/sms-gateway/main.go b/cmd/sms-gateway/main.go index 84da707..f85c19e 100644 --- a/cmd/sms-gateway/main.go +++ b/cmd/sms-gateway/main.go @@ -4,26 +4,28 @@ import ( smsgateway "github.com/capcom6/sms-gateway/internal/sms-gateway" ) -// @title SMS-шлюз - API сервера +// @title SMS Gateway - API // @version 1.0.0 -// @description Предоставляет методы для взаимодействия с SMS-шлюзом +// @description Provides API for (Android) SMS Gateway // @contact.name Aleksandr Soloshenko -// @contact.email capcom@soft-c.ru +// @contact.email i@capcom.me // @securitydefinitions.apikey MobileToken // @in header // @name Authorization -// @description Авторизацию устройства по токену +// @description Mobile device token // @securitydefinitions.basic ApiAuth -// @description Авторизацию пользователя по логин-паролю +// @in header +// @description End-user authentication key // @host localhost:3000 -// @schemes http +// @host sms.capcom.me +// @schemes https // @BasePath /api // -// SMS-шлюз +// SMS Gateway func main() { smsgateway.Run() } diff --git a/configs/config.example.yml b/configs/config.example.yml index f9a9a49..926ba16 100644 --- a/configs/config.example.yml +++ b/configs/config.example.yml @@ -1,21 +1,20 @@ +gateway: # gateway config + mode: private # gateway mode (public - allow anonymous device registration, private - protected registration) [GATEWAY__MODE] + private_token: 123456789 # access token for device registration in private mode [GATEWAY__PRIVATE_TOKEN] http: # http server config - listen: 127.0.0.1:3000 # listen address + listen: 127.0.0.1:3000 # listen address [HTTP__LISTEN] database: # database - dialect: mysql # database dialect - host: localhost # database host - port: 3306 # database port - user: root # database user - password: root # database password - database: sms # database name - debug: true - timezone: UTC -fcm: - credentials_json: > - { - ... - } - timeout_seconds: 1 - debounce_seconds: 1 -tasks: - hashing: - interval_seconds: 15 + dialect: mysql # database dialect (only mysql supported at the moment) [DATABASE__DIALECT] + host: localhost # database host [DATABASE__HOST] + port: 3306 # database port [DATABASE__PORT] + user: root # database user [DATABASE__USER] + password: root # database password [DATABASE__PASSWORD] + database: sms # database name [DATABASE__DATABASE] + timezone: UTC # database timezone (important for message TTL calculation) [DATABASE__TIMEZONE] +fcm: # firebase cloud messaging config + credentials_json: "{}" # firebase credentials json (for public mode only) [FCM__CREDENTIALS_JSON] + timeout_seconds: 1 # push notification send timeout [FCM__DEBOUNCE_SECONDS] + debounce_seconds: 5 # push notification debounce (>= 5s) [FCM__TIMEOUT_SECONDS] +tasks: # tasks config + hashing: # hashing task (hashes processed messages for privacy purposes) + interval_seconds: 15 # hashing interval in seconds [TASKS__HASHING__INTERVAL_SECONDS] diff --git a/deployments/docker-swarm-terraform/providers.tf b/deployments/docker-swarm-terraform/providers.tf index b781059..114f5de 100644 --- a/deployments/docker-swarm-terraform/providers.tf +++ b/deployments/docker-swarm-terraform/providers.tf @@ -10,9 +10,4 @@ terraform { provider "docker" { host = var.swarm-manager-host ssh_opts = ["-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"] - # registry_auth { - # address = "cr.selcloud.ru/soft-c" - # username = "token" - # password = var.registry-password - # } } diff --git a/deployments/docker-swarm-terraform/variables.tf b/deployments/docker-swarm-terraform/variables.tf index 094ea21..6b95e98 100644 --- a/deployments/docker-swarm-terraform/variables.tf +++ b/deployments/docker-swarm-terraform/variables.tf @@ -4,12 +4,6 @@ variable "swarm-manager-host" { description = "Address of swarm manager" } -# variable "registry-password" { -# type = string -# description = "Password for Docker Images Registry" -# sensitive = true -# } - variable "app-name" { type = string description = "Name of app" diff --git a/internal/config/config.go b/internal/config/config.go index 353c49c..d3d2cba 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,30 +1,43 @@ package config +type GatewayMode string + +const ( + GatewayModePublic GatewayMode = "public" + GatewayModePrivate GatewayMode = "private" +) + type Config struct { - HTTP HTTP `yaml:"http"` - Database Database `yaml:"database"` - FCM FCMConfig `yaml:"fcm"` - Tasks Tasks `yaml:"tasks"` + Gateway Gateway `yaml:"gateway"` // gateway config + HTTP HTTP `yaml:"http"` // http server config + Database Database `yaml:"database"` // database config + FCM FCMConfig `yaml:"fcm"` // firebase cloud messaging config + Tasks Tasks `yaml:"tasks"` // tasks config +} + +type Gateway struct { + Mode GatewayMode `yaml:"mode" envconfig:"GATEWAY__MODE"` // gateway mode: public or private + PrivateToken string `yaml:"private_token" envconfig:"GATEWAY__PRIVATE_TOKEN"` // device registration token in private mode } type HTTP struct { - Listen string `yaml:"listen" envconfig:"HTTP__LISTEN"` + Listen string `yaml:"listen" envconfig:"HTTP__LISTEN"` // listen address } type Database struct { - Dialect string `yaml:"dialect" envconfig:"DATABASE__DIALECT"` - Host string `yaml:"host" envconfig:"DATABASE__HOST"` - Port int `yaml:"port" envconfig:"DATABASE__PORT"` - User string `yaml:"user" envconfig:"DATABASE__USER"` - Password string `yaml:"password" envconfig:"DATABASE__PASSWORD"` - Database string `yaml:"database" envconfig:"DATABASE__DATABASE"` - Timezone string `yaml:"timezone" envconfig:"DATABASE__TIMEZONE"` + Dialect string `yaml:"dialect" envconfig:"DATABASE__DIALECT"` // database dialect + Host string `yaml:"host" envconfig:"DATABASE__HOST"` // database host + Port int `yaml:"port" envconfig:"DATABASE__PORT"` // database port + User string `yaml:"user" envconfig:"DATABASE__USER"` // database user + Password string `yaml:"password" envconfig:"DATABASE__PASSWORD"` // database password + Database string `yaml:"database" envconfig:"DATABASE__DATABASE"` // database name + Timezone string `yaml:"timezone" envconfig:"DATABASE__TIMEZONE"` // database timezone } type FCMConfig struct { - CredentialsJSON string `yaml:"credentials_json"` - DebounceSeconds uint16 `yaml:"debounce_seconds"` - TimeoutSeconds uint16 `yaml:"timeout_seconds"` + CredentialsJSON string `yaml:"credentials_json" envconfig:"FCM__CREDENTIALS_JSON"` // firebase credentials json (public mode only) + DebounceSeconds uint16 `yaml:"debounce_seconds" envconfig:"FCM__DEBOUNCE_SECONDS"` // push notification debounce (>= 5s) + TimeoutSeconds uint16 `yaml:"timeout_seconds" envconfig:"FCM__TIMEOUT_SECONDS"` // push notification send timeout } type Tasks struct { @@ -32,10 +45,11 @@ type Tasks struct { } type HashingTask struct { - IntervalSeconds uint16 `yaml:"interval_seconds"` + IntervalSeconds uint16 `yaml:"interval_seconds" envconfig:"TASKS__HASHING__INTERVAL_SECONDS"` // hashing interval in seconds } var defaultConfig = Config{ + Gateway: Gateway{Mode: GatewayModePublic}, HTTP: HTTP{ Listen: ":3000", }, diff --git a/internal/config/module.go b/internal/config/module.go index 2fee934..d6da29f 100644 --- a/internal/config/module.go +++ b/internal/config/module.go @@ -6,7 +6,9 @@ import ( "github.com/capcom6/go-infra-fx/config" "github.com/capcom6/go-infra-fx/db" "github.com/capcom6/go-infra-fx/http" - "github.com/capcom6/sms-gateway/internal/sms-gateway/services" + "github.com/capcom6/sms-gateway/internal/sms-gateway/handlers" + "github.com/capcom6/sms-gateway/internal/sms-gateway/modules/auth" + "github.com/capcom6/sms-gateway/internal/sms-gateway/modules/push" "github.com/capcom6/sms-gateway/internal/sms-gateway/tasks" "go.uber.org/fx" "go.uber.org/zap" @@ -39,11 +41,19 @@ var Module = fx.Module( Timezone: cfg.Database.Timezone, } }), - fx.Provide(func(cfg Config) services.PushServiceConfig { - return services.PushServiceConfig{ - CredentialsJSON: cfg.FCM.CredentialsJSON, - Debounce: time.Duration(cfg.FCM.DebounceSeconds) * time.Second, - Timeout: time.Duration(cfg.FCM.TimeoutSeconds) * time.Second, + fx.Provide(func(cfg Config) push.Config { + mode := push.ModeFCM + if cfg.Gateway.Mode == "private" { + mode = push.ModeUpstream + } + + return push.Config{ + Mode: mode, + ClientOptions: map[string]string{ + "credentials": cfg.FCM.CredentialsJSON, + }, + Debounce: time.Duration(cfg.FCM.DebounceSeconds) * time.Second, + Timeout: time.Duration(cfg.FCM.TimeoutSeconds) * time.Second, } }), fx.Provide(func(cfg Config) tasks.HashingTaskConfig { @@ -51,4 +61,15 @@ var Module = fx.Module( Interval: time.Duration(cfg.Tasks.Hashing.IntervalSeconds) * time.Second, } }), + fx.Provide(func(cfg Config) auth.Config { + return auth.Config{ + Mode: auth.Mode(cfg.Gateway.Mode), + PrivateToken: cfg.Gateway.PrivateToken, + } + }), + fx.Provide(func(cfg Config) handlers.Config { + return handlers.Config{ + GatewayMode: handlers.GatewayMode(cfg.Gateway.Mode), + } + }), ) diff --git a/internal/sms-gateway/app.go b/internal/sms-gateway/app.go index 70344fc..2bd6089 100644 --- a/internal/sms-gateway/app.go +++ b/internal/sms-gateway/app.go @@ -11,6 +11,8 @@ import ( "github.com/capcom6/go-infra-fx/validator" appconfig "github.com/capcom6/sms-gateway/internal/config" "github.com/capcom6/sms-gateway/internal/sms-gateway/handlers" + "github.com/capcom6/sms-gateway/internal/sms-gateway/modules/auth" + "github.com/capcom6/sms-gateway/internal/sms-gateway/modules/push" "github.com/capcom6/sms-gateway/internal/sms-gateway/repositories" "github.com/capcom6/sms-gateway/internal/sms-gateway/services" "github.com/capcom6/sms-gateway/internal/sms-gateway/tasks" @@ -28,6 +30,8 @@ var Module = fx.Module( validator.Module, handlers.Module, services.Module, + auth.Module, + push.Module, repositories.Module, db.Module, tasks.Module, @@ -55,7 +59,7 @@ type StartParams struct { Server *http.Server HashingTask *tasks.HashingTask - PushService *services.PushService + PushService *push.Service } func Start(p StartParams) error { diff --git a/internal/sms-gateway/handlers/3rdparty.go b/internal/sms-gateway/handlers/3rdparty.go index 52347cc..5a96b04 100644 --- a/internal/sms-gateway/handlers/3rdparty.go +++ b/internal/sms-gateway/handlers/3rdparty.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/capcom6/sms-gateway/internal/sms-gateway/models" + "github.com/capcom6/sms-gateway/internal/sms-gateway/modules/auth" "github.com/capcom6/sms-gateway/internal/sms-gateway/repositories" "github.com/capcom6/sms-gateway/internal/sms-gateway/services" "github.com/capcom6/sms-gateway/pkg/smsgateway" @@ -23,7 +24,7 @@ const ( type ThirdPartyHandlerParams struct { fx.In - AuthSvc *services.AuthService + AuthSvc *auth.Service MessagesSvc *services.MessagesService DevicesSvc *services.DevicesService @@ -34,23 +35,23 @@ type ThirdPartyHandlerParams struct { type thirdPartyHandler struct { Handler - authSvc *services.AuthService + authSvc *auth.Service messagesSvc *services.MessagesService devicesSvc *services.DevicesService } -// @Summary Получить устройства -// @Description Возвращает все устройства пользователя +// @Summary List devices +// @Description Returns list of registered devices // @Security ApiAuth -// @Tags Пользователь, Устройства +// @Tags User // @Produce json -// @Success 200 {object} []smsgateway.Device "Состояние сообщения" -// @Failure 401 {object} smsgateway.ErrorResponse "Ошибка авторизации" -// @Failure 400 {object} smsgateway.ErrorResponse "Некорректный запрос" -// @Failure 500 {object} smsgateway.ErrorResponse "Внутренняя ошибка сервера" +// @Success 200 {object} []smsgateway.Device "Device list" +// @Failure 400 {object} smsgateway.ErrorResponse "Invalid request" +// @Failure 401 {object} smsgateway.ErrorResponse "Unauthorized" +// @Failure 500 {object} smsgateway.ErrorResponse "Internal server error" // @Router /3rdparty/v1/device [get] // -// Получить устройства +// List devices func (h *thirdPartyHandler) getDevice(user models.User, c *fiber.Ctx) error { devices, err := h.devicesSvc.Select(user) if err != nil { @@ -73,22 +74,22 @@ func (h *thirdPartyHandler) getDevice(user models.User, c *fiber.Ctx) error { return c.JSON(response) } -// @Summary Поставить сообщение в очередь -// @Description Ставит сообщение в очередь на отправку. Если идентификатор не указан, то он будет сгенерирован автоматически +// @Summary Enqueue message +// @Description Enqueues message for sending. If ID is not specified, it will be generated // @Security ApiAuth -// @Tags Пользователь, Сообщения +// @Tags User, Messages // @Accept json // @Produce json -// @Param skipPhoneValidation query bool false "Пропустить проверку номеров телефона" -// @Param request body smsgateway.Message true "Сообщение" -// @Success 202 {object} smsgateway.MessageState "Сообщение поставлено в очередь" -// @Failure 401 {object} smsgateway.ErrorResponse "Ошибка авторизации" -// @Failure 400 {object} smsgateway.ErrorResponse "Некорректный запрос" -// @Failure 500 {object} smsgateway.ErrorResponse "Внутренняя ошибка сервера" -// @Header 202 {string} Location "URL для получения состояния сообщения" +// @Param skipPhoneValidation query bool false "Skip phone validation" +// @Param request body smsgateway.Message true "Send message request" +// @Success 202 {object} smsgateway.MessageState "Message enqueued" +// @Failure 400 {object} smsgateway.ErrorResponse "Invalid request" +// @Failure 401 {object} smsgateway.ErrorResponse "Unauthorized" +// @Failure 500 {object} smsgateway.ErrorResponse "Internal server error" +// @Header 202 {string} Location "Get message state URL" // @Router /3rdparty/v1/message [post] // -// Поставить сообщение в очередь +// Enqueue message func (h *thirdPartyHandler) postMessage(user models.User, c *fiber.Ctx) error { req := smsgateway.Message{} if err := h.BodyParserValidator(c, &req); err != nil { @@ -132,19 +133,19 @@ func (h *thirdPartyHandler) postMessage(user models.User, c *fiber.Ctx) error { return c.Status(fiber.StatusAccepted).JSON(state) } -// @Summary Получить состояние сообщения -// @Description Возвращает состояние сообщения по его ID +// @Summary Get message state +// @Description Returns message state by ID // @Security ApiAuth -// @Tags Пользователь, Сообщения +// @Tags User, Messages // @Produce json -// @Param id path string true "ИД сообщения" -// @Success 200 {object} smsgateway.MessageState "Состояние сообщения" -// @Failure 401 {object} smsgateway.ErrorResponse "Ошибка авторизации" -// @Failure 400 {object} smsgateway.ErrorResponse "Некорректный запрос" -// @Failure 500 {object} smsgateway.ErrorResponse "Внутренняя ошибка сервера" +// @Param id path string true "Message ID" +// @Success 200 {object} smsgateway.MessageState "Message state" +// @Failure 400 {object} smsgateway.ErrorResponse "Invalid request" +// @Failure 401 {object} smsgateway.ErrorResponse "Unauthorized" +// @Failure 500 {object} smsgateway.ErrorResponse "Internal server error" // @Router /3rdparty/v1/message [get] // -// Получить состояние сообщения +// Get message state func (h *thirdPartyHandler) getMessage(user models.User, c *fiber.Ctx) error { id := c.Params("id") diff --git a/internal/sms-gateway/handlers/config.go b/internal/sms-gateway/handlers/config.go new file mode 100644 index 0000000..e63f842 --- /dev/null +++ b/internal/sms-gateway/handlers/config.go @@ -0,0 +1,12 @@ +package handlers + +type GatewayMode string + +const ( + GatewayModePrivate GatewayMode = "private" + GatewayModePublic GatewayMode = "public" +) + +type Config struct { + GatewayMode GatewayMode +} diff --git a/internal/sms-gateway/handlers/mobile.go b/internal/sms-gateway/handlers/mobile.go index e595e67..38f63cb 100644 --- a/internal/sms-gateway/handlers/mobile.go +++ b/internal/sms-gateway/handlers/mobile.go @@ -7,6 +7,7 @@ import ( "github.com/capcom6/go-infra-fx/http/apikey" "github.com/capcom6/sms-gateway/internal/sms-gateway/models" + "github.com/capcom6/sms-gateway/internal/sms-gateway/modules/auth" "github.com/capcom6/sms-gateway/internal/sms-gateway/repositories" "github.com/capcom6/sms-gateway/internal/sms-gateway/services" "github.com/capcom6/sms-gateway/pkg/smsgateway" @@ -21,24 +22,26 @@ import ( type mobileHandler struct { Handler - authSvc *services.AuthService + authSvc *auth.Service messagesSvc *services.MessagesService idGen func() string } -// @Summary Регистрация устройства -// @Description Регистрирует устройство на сервере, генерируя авторизационные данные -// @Tags Устройство, Регистрация +// @Summary Register device +// @Description Registers new device and returns credentials +// @Tags Device // @Accept json // @Produce json -// @Param request body smsgateway.MobileRegisterRequest true "Запрос на регистрацию" -// @Success 201 {object} smsgateway.MobileRegisterResponse "Успешная регистрация" -// @Failure 400 {object} smsgateway.ErrorResponse "Некорректный запрос" -// @Failure 500 {object} smsgateway.ErrorResponse "Внутренняя ошибка сервера" +// @Param request body smsgateway.MobileRegisterRequest true "Device registration request" +// @Success 201 {object} smsgateway.MobileRegisterResponse "Device registered" +// @Failure 400 {object} smsgateway.ErrorResponse "Invalid request" +// @Failure 401 {object} smsgateway.ErrorResponse "Unauthorized (private mode only)" +// @Failure 429 {object} smsgateway.ErrorResponse "Too many requests" +// @Failure 500 {object} smsgateway.ErrorResponse "Internal server error" // @Router /mobile/v1/device [post] // -// Регистрация устройства +// Register device func (h *mobileHandler) postDevice(c *fiber.Ctx) error { req := smsgateway.MobileRegisterRequest{} @@ -68,19 +71,19 @@ func (h *mobileHandler) postDevice(c *fiber.Ctx) error { }) } -// @Summary Обновление устройства -// @Description Обновляет push-токен устройства +// @Summary Update device +// @Description Updates push token for device // @Security MobileToken -// @Tags Устройство +// @Tags Device // @Accept json -// @Param request body smsgateway.MobileUpdateRequest true "Запрос на обновление" -// @Success 204 "Успешное обновление" -// @Failure 400 {object} smsgateway.ErrorResponse "Некорректный запрос" -// @Failure 403 {object} smsgateway.ErrorResponse "Операция запрещена" -// @Failure 500 {object} smsgateway.ErrorResponse "Внутренняя ошибка сервера" +// @Param request body smsgateway.MobileUpdateRequest true "Device update request" +// @Success 204 "Successfully updated" +// @Failure 400 {object} smsgateway.ErrorResponse "Invalid request" +// @Failure 403 {object} smsgateway.ErrorResponse "Forbidden (wrong device ID)" +// @Failure 500 {object} smsgateway.ErrorResponse "Internal server error" // @Router /mobile/v1/device [patch] // -// Обновление устройства +// Update device func (h *mobileHandler) patchDevice(device models.Device, c *fiber.Ctx) error { req := smsgateway.MobileUpdateRequest{} @@ -99,17 +102,17 @@ func (h *mobileHandler) patchDevice(device models.Device, c *fiber.Ctx) error { return c.SendStatus(fiber.StatusNoContent) } -// @Summary Получить сообщения для отправки -// @Description Возвращает список сообщений, требующих отправки +// @Summary Get messages for sending +// @Description Returns list of pending messages // @Security MobileToken -// @Tags Устройство, Сообщения +// @Tags Device, Messages // @Accept json // @Produce json -// @Success 200 {array} smsgateway.Message "Список сообщений" -// @Failure 500 {object} smsgateway.ErrorResponse "Внутренняя ошибка сервера" +// @Success 200 {array} smsgateway.Message "List of pending messages" +// @Failure 500 {object} smsgateway.ErrorResponse "Internal server error" // @Router /mobile/v1/message [get] // -// Получить сообщения для отправки +// Get messages for sending func (h *mobileHandler) getMessage(device models.Device, c *fiber.Ctx) error { messages, err := h.messagesSvc.SelectPending(device.ID) if err != nil { @@ -119,19 +122,19 @@ func (h *mobileHandler) getMessage(device models.Device, c *fiber.Ctx) error { return c.JSON(messages) } -// @Summary Обновить состояние сообщений -// @Description Обновляет состояние сообщений. Состояние обновляется индивидуально для каждого сообщения, игнорируя ошибки +// @Summary Update message state +// @Description Updates message state // @Security MobileToken -// @Tags Устройство, Сообщения +// @Tags Device, Messages // @Accept json // @Produce json -// @Param request body []smsgateway.MessageState true "Состояние сообщений" -// @Success 204 {object} nil "Обновление выполнено" -// @Failure 400 {object} smsgateway.ErrorResponse "Некорректный запрос" -// @Failure 500 {object} smsgateway.ErrorResponse "Внутренняя ошибка сервера" +// @Param request body []smsgateway.MessageState true "New message state" +// @Success 204 {object} nil "Successfully updated" +// @Failure 400 {object} smsgateway.ErrorResponse "Invalid request" +// @Failure 500 {object} smsgateway.ErrorResponse "Internal server error" // @Router /mobile/v1/message [patch] // -// Обновить состояние сообщений +// Update message state func (h *mobileHandler) patchMessage(device models.Device, c *fiber.Ctx) error { req := []smsgateway.MessageState{} if err := c.BodyParser(&req); err != nil { @@ -169,7 +172,12 @@ func (h *mobileHandler) authorize(handler func(models.Device, *fiber.Ctx) error) func (h *mobileHandler) Register(router fiber.Router) { router = router.Group("/mobile/v1") - router.Post("/device", limiter.New(), h.postDevice) + router.Post("/device", limiter.New(), apikey.New(apikey.Config{ + Next: func(c *fiber.Ctx) bool { return h.authSvc.IsPublic() }, + Authorizer: func(token string) bool { + return h.authSvc.AuthorizeRegistration(token) == nil + }, + }), h.postDevice) router.Use(apikey.New(apikey.Config{ Authorizer: func(token string) bool { @@ -189,7 +197,7 @@ type MobileHandlerParams struct { Logger *zap.Logger Validator *validator.Validate - AuthSvc *services.AuthService + AuthSvc *auth.Service MessagesSvc *services.MessagesService } diff --git a/internal/sms-gateway/handlers/module.go b/internal/sms-gateway/handlers/module.go index 34683c4..64906bc 100644 --- a/internal/sms-gateway/handlers/module.go +++ b/internal/sms-gateway/handlers/module.go @@ -15,5 +15,6 @@ var Module = fx.Module( http.AsRootHandler(newRootHandler), http.AsApiHandler(newThirdPartyHandler), http.AsApiHandler(newMobileHandler), + http.AsApiHandler(newUpstreamHandler), ), ) diff --git a/internal/sms-gateway/handlers/upstream.go b/internal/sms-gateway/handlers/upstream.go new file mode 100644 index 0000000..132d640 --- /dev/null +++ b/internal/sms-gateway/handlers/upstream.go @@ -0,0 +1,90 @@ +package handlers + +import ( + "time" + + "github.com/capcom6/sms-gateway/internal/sms-gateway/modules/push" + "github.com/capcom6/sms-gateway/pkg/smsgateway" + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/limiter" + "go.uber.org/fx" + "go.uber.org/zap" +) + +type upstreamHandler struct { + Handler + + config Config + pushSvc *push.Service +} + +type upstreamHandlerParams struct { + fx.In + + Config Config + PushSvc *push.Service + + Logger *zap.Logger + Validator *validator.Validate +} + +func newUpstreamHandler(params upstreamHandlerParams) *upstreamHandler { + return &upstreamHandler{ + Handler: Handler{Logger: params.Logger, Validator: params.Validator}, + config: params.Config, + pushSvc: params.PushSvc, + } +} + +// @Summary Send push notifications +// @Description Enqueues notifications for sending to devices +// @Tags Device, Upstream +// @Accept json +// @Produce json +// @Param request body smsgateway.UpstreamPushRequest true "Push request" +// @Success 202 "Notification enqueued" +// @Failure 400 {object} smsgateway.ErrorResponse "Invalid request" +// @Failure 429 {object} smsgateway.ErrorResponse "Too many requests" +// @Failure 500 {object} smsgateway.ErrorResponse "Internal server error" +// @Router /upstream/v1/push [post] +// +// Send push notifications +func (h *upstreamHandler) postPush(c *fiber.Ctx) error { + req := smsgateway.UpstreamPushRequest{} + + if err := c.BodyParser(&req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if len(req) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Empty request") + } + + for _, v := range req { + if err := h.validateStruct(v); err != nil { + return err + } + + if err := h.pushSvc.Enqueue(c.Context(), v.Token, map[string]string{}); err != nil { + h.Logger.Error("Can't push message", zap.Error(err)) + } + } + + return c.SendStatus(fiber.StatusAccepted) +} + +func (h *upstreamHandler) Register(router fiber.Router) { + // register only in public mode + if h.config.GatewayMode != GatewayModePublic { + return + } + + router = router.Group("/upstream/v1") + + router.Post("/push", limiter.New(limiter.Config{ + Max: 5, + Expiration: 60 * time.Second, + LimiterMiddleware: limiter.SlidingWindow{}, + }), h.postPush) +} diff --git a/internal/sms-gateway/modules/auth/module.go b/internal/sms-gateway/modules/auth/module.go new file mode 100644 index 0000000..85d4c69 --- /dev/null +++ b/internal/sms-gateway/modules/auth/module.go @@ -0,0 +1,14 @@ +package auth + +import ( + "go.uber.org/fx" + "go.uber.org/zap" +) + +var Module = fx.Module( + "auth", + fx.Decorate(func(log *zap.Logger) *zap.Logger { + return log.Named("auth") + }), + fx.Provide(New), +) diff --git a/internal/sms-gateway/services/auth.go b/internal/sms-gateway/modules/auth/service.go similarity index 60% rename from internal/sms-gateway/services/auth.go rename to internal/sms-gateway/modules/auth/service.go index 515afb9..0cbf61a 100644 --- a/internal/sms-gateway/services/auth.go +++ b/internal/sms-gateway/modules/auth/service.go @@ -1,4 +1,4 @@ -package services +package auth import ( "fmt" @@ -11,7 +11,25 @@ import ( "go.uber.org/zap" ) -type AuthService struct { +type Config struct { + Mode Mode + PrivateToken string +} + +type Params struct { + fx.In + + Config Config + + Users *repositories.UsersRepository + Devices *repositories.DevicesRepository + + Logger *zap.Logger +} + +type Service struct { + config Config + users *repositories.UsersRepository devices *repositories.DevicesRepository @@ -20,14 +38,26 @@ type AuthService struct { idgen func() string } -func (s *AuthService) RegisterUser(login, password string) (models.User, error) { +func New(params Params) *Service { + idgen, _ := nanoid.Standard(21) + + return &Service{ + config: params.Config, + users: params.Users, + devices: params.Devices, + logger: params.Logger.Named("Service"), + idgen: idgen, + } +} + +func (s *Service) RegisterUser(login, password string) (models.User, error) { user := models.User{ ID: login, } var err error if user.PasswordHash, err = crypto.MakeBCryptHash(password); err != nil { - return user, err + return user, fmt.Errorf("can't hash password: %w", err) } if err = s.users.Insert(&user); err != nil { @@ -37,7 +67,7 @@ func (s *AuthService) RegisterUser(login, password string) (models.User, error) return user, nil } -func (s *AuthService) RegisterDevice(userID string, name, pushToken *string) (models.Device, error) { +func (s *Service) RegisterDevice(userID string, name, pushToken *string) (models.Device, error) { device := models.Device{ ID: s.idgen(), Name: name, @@ -49,11 +79,27 @@ func (s *AuthService) RegisterDevice(userID string, name, pushToken *string) (mo return device, s.devices.Insert(&device) } -func (s *AuthService) UpdateDevice(id, pushToken string) error { +func (s *Service) UpdateDevice(id, pushToken string) error { return s.devices.UpdateToken(id, pushToken) } -func (s *AuthService) AuthorizeDevice(token string) (models.Device, error) { +func (s *Service) IsPublic() bool { + return s.config.Mode == ModePublic +} + +func (s *Service) AuthorizeRegistration(token string) error { + if s.IsPublic() { + return nil + } + + if token == s.config.PrivateToken { + return nil + } + + return fmt.Errorf("invalid token") +} + +func (s *Service) AuthorizeDevice(token string) (models.Device, error) { device, err := s.devices.GetByToken(token) if err != nil { return device, err @@ -66,7 +112,7 @@ func (s *AuthService) AuthorizeDevice(token string) (models.Device, error) { return device, nil } -func (s *AuthService) AuthorizeUser(username, password string) (models.User, error) { +func (s *Service) AuthorizeUser(username, password string) (models.User, error) { user, err := s.users.GetByLogin(username) if err != nil { return user, err @@ -74,23 +120,3 @@ func (s *AuthService) AuthorizeUser(username, password string) (models.User, err return user, crypto.CompareBCryptHash(user.PasswordHash, password) } - -type AuthServiceParams struct { - fx.In - - Users *repositories.UsersRepository - Devices *repositories.DevicesRepository - - Logger *zap.Logger -} - -func NewAuthService(params AuthServiceParams) *AuthService { - idgen, _ := nanoid.Standard(21) - - return &AuthService{ - users: params.Users, - devices: params.Devices, - logger: params.Logger.Named("AuthService"), - idgen: idgen, - } -} diff --git a/internal/sms-gateway/modules/auth/types.go b/internal/sms-gateway/modules/auth/types.go new file mode 100644 index 0000000..88607fc --- /dev/null +++ b/internal/sms-gateway/modules/auth/types.go @@ -0,0 +1,8 @@ +package auth + +type Mode string + +const ( + ModePublic Mode = "public" + ModePrivate Mode = "private" +) diff --git a/internal/sms-gateway/modules/push/fcm/client.go b/internal/sms-gateway/modules/push/fcm/client.go new file mode 100644 index 0000000..729947e --- /dev/null +++ b/internal/sms-gateway/modules/push/fcm/client.go @@ -0,0 +1,78 @@ +package fcm + +import ( + "context" + "errors" + "fmt" + "sync" + + firebase "firebase.google.com/go/v4" + "firebase.google.com/go/v4/messaging" + "google.golang.org/api/option" +) + +type Client struct { + options map[string]string + + client *messaging.Client + mux sync.Mutex +} + +func New(options map[string]string) (*Client, error) { + return &Client{ + options: options, + }, nil +} + +func (c *Client) Open(ctx context.Context) error { + c.mux.Lock() + defer c.mux.Unlock() + + if c.client != nil { + return nil + } + + creds := c.options["credentials"] + if creds == "" { + return fmt.Errorf("no credentials provided") + } + + opt := option.WithCredentialsJSON([]byte(creds)) + + app, err := firebase.NewApp(ctx, nil, opt) + if err != nil { + return fmt.Errorf("can't create firebase app: %w", err) + } + + c.client, err = app.Messaging(ctx) + if err != nil { + return fmt.Errorf("can't create firebase messaging client: %w", err) + } + + return nil +} + +func (c *Client) Send(ctx context.Context, messages map[string]map[string]string) error { + errs := make([]error, 0, len(messages)) + for address, payload := range messages { + _, err := c.client.Send(ctx, &messaging.Message{ + Data: payload, + Android: &messaging.AndroidConfig{ + Priority: "high", + }, + Token: address, + }) + + if err != nil { + errs = append(errs, fmt.Errorf("can't send message to %s: %w", address, err)) + } + } + + return errors.Join(errs...) +} + +func (c *Client) Close(ctx context.Context) error { + c.client = nil + + return nil +} diff --git a/internal/sms-gateway/modules/push/module.go b/internal/sms-gateway/modules/push/module.go new file mode 100644 index 0000000..9a0b917 --- /dev/null +++ b/internal/sms-gateway/modules/push/module.go @@ -0,0 +1,47 @@ +package push + +import ( + "context" + "errors" + + "github.com/capcom6/sms-gateway/internal/sms-gateway/modules/push/fcm" + "github.com/capcom6/sms-gateway/internal/sms-gateway/modules/push/upstream" + "go.uber.org/fx" + "go.uber.org/zap" +) + +var Module = fx.Module( + "push", + fx.Decorate(func(log *zap.Logger) *zap.Logger { + return log.Named("push") + }), + fx.Provide( + func(cfg Config, lc fx.Lifecycle) (c client, err error) { + if cfg.Mode == ModeFCM { + c, err = fcm.New(cfg.ClientOptions) + } else if cfg.Mode == ModeUpstream { + c, err = upstream.New(cfg.ClientOptions) + } else { + return nil, errors.New("invalid push mode") + } + + if err != nil { + return nil, err + } + + lc.Append(fx.Hook{ + OnStart: func(ctx context.Context) error { + return c.Open(ctx) + }, + OnStop: func(ctx context.Context) error { + return c.Close(ctx) + }, + }) + + return c, nil + }, + ), + fx.Provide( + New, + ), +) diff --git a/internal/sms-gateway/modules/push/service.go b/internal/sms-gateway/modules/push/service.go new file mode 100644 index 0000000..d69cc0f --- /dev/null +++ b/internal/sms-gateway/modules/push/service.go @@ -0,0 +1,92 @@ +package push + +import ( + "context" + "time" + + "github.com/capcom6/sms-gateway/pkg/types/cache" + "go.uber.org/fx" + "go.uber.org/zap" +) + +type Config struct { + Mode Mode + + ClientOptions map[string]string + + Debounce time.Duration + Timeout time.Duration +} + +type Params struct { + fx.In + + Config Config + + Client client + + Logger *zap.Logger +} + +type Service struct { + config Config + + client client + + cache *cache.Cache[map[string]string] + + logger *zap.Logger +} + +func New(params Params) *Service { + if params.Config.Timeout == 0 { + params.Config.Timeout = time.Second + } + if params.Config.Debounce < 5*time.Second { + params.Config.Debounce = 5 * time.Second + } + + return &Service{ + config: params.Config, + client: params.Client, + cache: cache.New[map[string]string](), + logger: params.Logger, + } +} + +// Run runs the service with the provided context if a debounce is set. +func (s *Service) Run(ctx context.Context) { + ticker := time.NewTicker(s.config.Debounce) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.sendAll(ctx) + } + } +} + +// Enqueue adds the data to the cache and immediately sends all messages if the debounce is 0. +func (s *Service) Enqueue(ctx context.Context, token string, data map[string]string) error { + s.cache.Set(token, data) + + return nil +} + +// sendAll sends messages to all targets from the cache after initializing the service. +func (s *Service) sendAll(ctx context.Context) { + targets := s.cache.Drain() + if len(targets) == 0 { + return + } + + s.logger.Info("Sending messages", zap.Int("count", len(targets))) + ctx, cancel := context.WithTimeout(ctx, s.config.Timeout) + if err := s.client.Send(ctx, targets); err != nil { + s.logger.Error("Can't send messages", zap.Error(err)) + } + cancel() +} diff --git a/internal/sms-gateway/modules/push/types.go b/internal/sms-gateway/modules/push/types.go new file mode 100644 index 0000000..a072dcd --- /dev/null +++ b/internal/sms-gateway/modules/push/types.go @@ -0,0 +1,16 @@ +package push + +import "context" + +type Mode string + +const ( + ModeFCM Mode = "fcm" + ModeUpstream Mode = "upstream" +) + +type client interface { + Open(ctx context.Context) error + Send(ctx context.Context, messages map[string]map[string]string) error + Close(ctx context.Context) error +} diff --git a/internal/sms-gateway/modules/push/upstream/client.go b/internal/sms-gateway/modules/push/upstream/client.go new file mode 100644 index 0000000..2b7d8eb --- /dev/null +++ b/internal/sms-gateway/modules/push/upstream/client.go @@ -0,0 +1,89 @@ +package upstream + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "sync" + + "github.com/capcom6/sms-gateway/pkg/smsgateway" +) + +const BASE_URL = "https://sms.capcom.me/api/upstream/v1" + +type Client struct { + options map[string]string + + client *http.Client + mux sync.Mutex +} + +func New(options map[string]string) (*Client, error) { + return &Client{ + options: options, + }, nil +} + +func (c *Client) Open(ctx context.Context) error { + c.mux.Lock() + defer c.mux.Unlock() + + if c.client != nil { + return nil + } + + c.client = &http.Client{} + + return nil +} + +func (c *Client) Send(ctx context.Context, messages map[string]map[string]string) error { + payload := make(smsgateway.UpstreamPushRequest, 0, len(messages)) + + for address := range messages { + payload = append(payload, smsgateway.PushNotification{ + Token: address, + }) + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("can't marshal payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, BASE_URL+"/push", bytes.NewReader(payloadBytes)) + if err != nil { + return fmt.Errorf("can't create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "android-sms-gateway/1.x (server; golang)") + + resp, err := c.client.Do(req) + if err != nil { + return fmt.Errorf("can't send request: %w", err) + } + + defer func() { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + }() + + if resp.StatusCode >= 400 { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + return nil +} + +func (c *Client) Close(ctx context.Context) error { + c.mux.Lock() + defer c.mux.Unlock() + + c.client = nil + + return nil +} diff --git a/internal/sms-gateway/services/messages.go b/internal/sms-gateway/services/messages.go index b8a7922..3c96360 100644 --- a/internal/sms-gateway/services/messages.go +++ b/internal/sms-gateway/services/messages.go @@ -7,6 +7,7 @@ import ( "time" "github.com/capcom6/sms-gateway/internal/sms-gateway/models" + "github.com/capcom6/sms-gateway/internal/sms-gateway/modules/push" "github.com/capcom6/sms-gateway/internal/sms-gateway/repositories" "github.com/capcom6/sms-gateway/pkg/slices" "github.com/capcom6/sms-gateway/pkg/smsgateway" @@ -35,13 +36,13 @@ type MessagesServiceParams struct { fx.In Messages *repositories.MessagesRepository - PushSvc *PushService + PushSvc *push.Service Logger *zap.Logger } type MessagesService struct { Messages *repositories.MessagesRepository - PushSvc *PushService + PushSvc *push.Service Logger *zap.Logger idgen func() string diff --git a/internal/sms-gateway/services/module.go b/internal/sms-gateway/services/module.go index 9553ade..7c92b51 100644 --- a/internal/sms-gateway/services/module.go +++ b/internal/sms-gateway/services/module.go @@ -11,9 +11,7 @@ var Module = fx.Module( return log.Named("services") }), fx.Provide( - NewAuthService, NewMessagesService, - NewPushService, NewDevicesService, ), ) diff --git a/internal/sms-gateway/services/push.go b/internal/sms-gateway/services/push.go deleted file mode 100644 index 4e58441..0000000 --- a/internal/sms-gateway/services/push.go +++ /dev/null @@ -1,139 +0,0 @@ -package services - -import ( - "context" - "sync" - "time" - - firebase "firebase.google.com/go/v4" - "firebase.google.com/go/v4/messaging" - "github.com/capcom6/sms-gateway/pkg/types/cache" - "go.uber.org/fx" - "go.uber.org/zap" - "google.golang.org/api/option" -) - -type PushServiceParams struct { - fx.In - - Config PushServiceConfig - Logger *zap.Logger -} - -type PushService struct { - Config PushServiceConfig - - Logger *zap.Logger - - client *messaging.Client - mux sync.Mutex - - cache *cache.Cache[map[string]string] -} - -type PushServiceConfig struct { - CredentialsJSON string - Debounce time.Duration - Timeout time.Duration -} - -// NewPushService creates a new PushService. -func NewPushService(params PushServiceParams) *PushService { - if params.Config.Timeout == 0 { - params.Config.Timeout = time.Second - } - - return &PushService{ - Config: params.Config, - Logger: params.Logger, - cache: cache.New[map[string]string](), - } -} - -// init initializes the FCM client. -func (s *PushService) init(ctx context.Context) (err error) { - s.mux.Lock() - defer s.mux.Unlock() - - if s.client != nil { - return - } - - opt := option.WithCredentialsJSON([]byte(s.Config.CredentialsJSON)) - - var app *firebase.App - app, err = firebase.NewApp(ctx, nil, opt) - - if err != nil { - return - } - - s.client, err = app.Messaging(ctx) - - return -} - -// sendAll sends messages to all targets from the cache after initializing the service. -func (s *PushService) sendAll(ctx context.Context) { - if err := s.init(ctx); err != nil { - s.Logger.Error("Can't init push service", zap.Error(err)) - return - } - - targets := s.cache.Drain() - if len(targets) == 0 { - return - } - - s.Logger.Info("Sending messages", zap.Int("count", len(targets))) - for token, data := range targets { - singleCtx, cancel := context.WithTimeout(ctx, s.Config.Timeout) - if err := s.sendSingle(singleCtx, token, data); err != nil { - s.Logger.Error("Can't send message", zap.String("token", token), zap.Error(err)) - } - cancel() - } -} - -// sendSingle sends a single message to the specified token -func (s *PushService) sendSingle(ctx context.Context, token string, data map[string]string) error { - _, err := s.client.Send(ctx, &messaging.Message{ - Data: data, - Android: &messaging.AndroidConfig{ - Priority: "high", - }, - Token: token, - }) - - return err -} - -// Run runs the service with the provided context if a debounce is set. -func (s *PushService) Run(ctx context.Context) { - if s.Config.Debounce == 0 { - return - } - - ticker := time.NewTicker(s.Config.Debounce) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - s.sendAll(ctx) - } - } -} - -// Enqueue adds the data to the cache and immediately sends all messages if the debounce is 0. -func (s *PushService) Enqueue(ctx context.Context, token string, data map[string]string) error { - s.cache.Set(token, data) - - if s.Config.Debounce == 0 { - s.sendAll(ctx) - } - - return nil -} diff --git a/pkg/smsgateway/domain.go b/pkg/smsgateway/domain.go index 062b612..e2aade6 100644 --- a/pkg/smsgateway/domain.go +++ b/pkg/smsgateway/domain.go @@ -6,35 +6,35 @@ import ( ) const ( - MessageStatePending ProcessState = "Pending" // В ожидании - MessageStateProcessed ProcessState = "Processed" // Обработано - MessageStateSent ProcessState = "Sent" // Отправлено - MessageStateDelivered ProcessState = "Delivered" // Доставлено - MessageStateFailed ProcessState = "Failed" // Ошибка + MessageStatePending ProcessState = "Pending" // Pending + MessageStateProcessed ProcessState = "Processed" // Processed (received by device) + MessageStateSent ProcessState = "Sent" // Sent + MessageStateDelivered ProcessState = "Delivered" // Delivered + MessageStateFailed ProcessState = "Failed" // Failed ) -// Устройство +// Device type Device struct { - ID string `json:"id" example:"PyDmBQZZXYmyxMwED8Fzy"` // Идентификатор - Name string `json:"name" example:"My Device"` // Название устройства - CreatedAt time.Time `json:"createdAt" example:"2020-01-01T00:00:00Z"` // Дата создания - UpdatedAt time.Time `json:"updatedAt" example:"2020-01-01T00:00:00Z"` // Дата обновления - DeletedAt *time.Time `json:"deletedAt,omitempty" example:"2020-01-01T00:00:00Z"` // Дата удаления + ID string `json:"id" example:"PyDmBQZZXYmyxMwED8Fzy"` // ID + Name string `json:"name" example:"My Device"` // Name + CreatedAt time.Time `json:"createdAt" example:"2020-01-01T00:00:00Z"` // Created at (read only) + UpdatedAt time.Time `json:"updatedAt" example:"2020-01-01T00:00:00Z"` // Updated at (read only) + DeletedAt *time.Time `json:"deletedAt,omitempty" example:"2020-01-01T00:00:00Z"` // Deleted at (read only) - LastSeen time.Time `json:"lastSeen" example:"2020-01-01T00:00:00Z"` // Последняя активность + LastSeen time.Time `json:"lastSeen" example:"2020-01-01T00:00:00Z"` // Last seen at (read only) } -// Сообщение +// Message 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!"` // Текст сообщения - 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"` // Время окончания жизни сообщения + ID string `json:"id,omitempty" validate:"omitempty,max=36" example:"PyDmBQZZXYmyxMwED8Fzy"` // ID (if not set - will be generated) + Message string `json:"message" validate:"required,max=65535" example:"Hello World!"` // Content + SimNumber *uint8 `json:"simNumber,omitempty" validate:"omitempty,max=3" example:"1"` // SIM card number (1-3) + WithDeliveryReport *bool `json:"withDeliveryReport,omitempty" example:"true"` // With delivery report + IsEncrypted bool `json:"isEncrypted,omitempty" example:"true"` // Is encrypted + PhoneNumbers []string `json:"phoneNumbers" validate:"required,min=1,max=100,dive,required,min=10,max=128" example:"79990001234"` // Recipients (phone numbers) + + TTL *uint64 `json:"ttl,omitempty" validate:"omitempty,min=5" example:"86400"` // Time to live in seconds (conflicts with `validUntil`) + ValidUntil *time.Time `json:"validUntil,omitempty" example:"2020-01-01T00:00:00Z"` // Valid until (conflicts with `ttl`) } func (m Message) Validate() error { @@ -45,18 +45,23 @@ func (m Message) Validate() error { return nil } -// Состояние сообщения +// Message state type MessageState struct { - ID string `json:"id,omitempty" validate:"omitempty,max=36" example:"PyDmBQZZXYmyxMwED8Fzy"` // Идентификатор - State ProcessState `json:"state" validate:"required" example:"Pending"` // Состояние - IsHashed bool `json:"isHashed" example:"false"` // Хэшировано - IsEncrypted bool `json:"isEncrypted" example:"false"` // Зашифровано - Recipients []RecipientState `json:"recipients" validate:"required,min=1,dive"` // Детализация состояния по получателям + ID string `json:"id,omitempty" validate:"omitempty,max=36" example:"PyDmBQZZXYmyxMwED8Fzy"` // Message ID + State ProcessState `json:"state" validate:"required" example:"Pending"` // State + IsHashed bool `json:"isHashed" example:"false"` // Hashed + IsEncrypted bool `json:"isEncrypted" example:"false"` // Encrypted + Recipients []RecipientState `json:"recipients" validate:"required,min=1,dive"` // Recipients states } -// Детализация состояния +// Recipient state type RecipientState struct { - PhoneNumber string `json:"phoneNumber" validate:"required,min=10,max=128" example:"79990001234"` // Номер телефона или первые 16 символов SHA256 - State ProcessState `json:"state" validate:"required" example:"Pending"` // Состояние - Error *string `json:"error,omitempty" example:"timeout"` // Ошибка + PhoneNumber string `json:"phoneNumber" validate:"required,min=10,max=128" example:"79990001234"` // Phone number or first 16 symbols of SHA256 hash + State ProcessState `json:"state" validate:"required" example:"Pending"` // State + Error *string `json:"error,omitempty" example:"timeout"` // Error (for `Failed` state) +} + +// Push notification +type PushNotification struct { + Token string `json:"token" validate:"required" example:"PyDmBQZZXYmyxMwED8Fzy"` // Device FCM token } diff --git a/pkg/smsgateway/requests.go b/pkg/smsgateway/requests.go index ca56a70..c4d66b0 100644 --- a/pkg/smsgateway/requests.go +++ b/pkg/smsgateway/requests.go @@ -1,13 +1,16 @@ package smsgateway -// Запрос на регистрацию устройства +// Device registration request type MobileRegisterRequest struct { - Name *string `json:"name,omitempty" validate:"omitempty,max=128" example:"Android Phone"` // Имя устройства - PushToken *string `json:"pushToken" validate:"omitempty,max=256" example:"gHz-T6NezDlOfllr7F-Be"` // Токен для отправки PUSH-уведомлений + Name *string `json:"name,omitempty" validate:"omitempty,max=128" example:"Android Phone"` // Device name + PushToken *string `json:"pushToken" validate:"omitempty,max=256" example:"gHz-T6NezDlOfllr7F-Be"` // FCM token } -// Запрос на обновление данных об устройстве +// Device update request type MobileUpdateRequest struct { - Id string `json:"id" example:"QslD_GefqiYV6RQXdkM6V"` // Идентификатор, если есть - PushToken string `json:"pushToken" validate:"omitempty,max=256" example:"gHz-T6NezDlOfllr7F-Be"` // Токен для отправки PUSH-уведомлений + Id string `json:"id" example:"QslD_GefqiYV6RQXdkM6V"` // ID + PushToken string `json:"pushToken" validate:"omitempty,max=256" example:"gHz-T6NezDlOfllr7F-Be"` // FCM token } + +// Push request +type UpstreamPushRequest = []PushNotification diff --git a/pkg/smsgateway/responses.go b/pkg/smsgateway/responses.go index 7f97b72..54ba143 100644 --- a/pkg/smsgateway/responses.go +++ b/pkg/smsgateway/responses.go @@ -1,16 +1,16 @@ package smsgateway -// Успешная регистрация устройства +// Device registration response type MobileRegisterResponse struct { - Id string `json:"id" example:"QslD_GefqiYV6RQXdkM6V"` // Идентификатор - Token string `json:"token" example:"bP0ZdK6rC6hCYZSjzmqhQ"` // Ключ доступа - Login string `json:"login" example:"VQ4GII"` // Логин пользователя - Password string `json:"password" example:"cp2pydvxd2zwpx"` // Пароль пользователя + Id string `json:"id" example:"QslD_GefqiYV6RQXdkM6V"` // New device ID + Token string `json:"token" example:"bP0ZdK6rC6hCYZSjzmqhQ"` // Device access token + Login string `json:"login" example:"VQ4GII"` // User login + Password string `json:"password" example:"cp2pydvxd2zwpx"` // User password } -// Сообщение об ошибке +// Error response type ErrorResponse struct { - Message string `json:"message" example:"Произошла ошибка"` // текст ошибки - Code int32 `json:"code,omitempty"` // код ошибки - Data any `json:"data,omitempty"` // контекст + Message string `json:"message" example:"An error occurred"` // Error message + Code int32 `json:"code,omitempty"` // Error code + Data any `json:"data,omitempty"` // Error context } diff --git a/web/mkdocs/docs/assets/cloud-server-arch.png b/web/mkdocs/docs/assets/cloud-server-arch.png new file mode 100644 index 0000000..d9c68c4 Binary files /dev/null and b/web/mkdocs/docs/assets/cloud-server-arch.png differ diff --git a/web/mkdocs/docs/assets/local-server-arch.png b/web/mkdocs/docs/assets/local-server-arch.png new file mode 100644 index 0000000..8a4ba6f Binary files /dev/null and b/web/mkdocs/docs/assets/local-server-arch.png differ diff --git a/web/mkdocs/docs/assets/private-server-arch.png b/web/mkdocs/docs/assets/private-server-arch.png new file mode 100644 index 0000000..649a4aa Binary files /dev/null and b/web/mkdocs/docs/assets/private-server-arch.png differ diff --git a/web/mkdocs/docs/assets/private-server.png b/web/mkdocs/docs/assets/private-server.png new file mode 100644 index 0000000..cb36f5f Binary files /dev/null and b/web/mkdocs/docs/assets/private-server.png differ diff --git a/web/mkdocs/docs/getting-started.md b/web/mkdocs/docs/getting-started.md deleted file mode 100644 index b370748..0000000 --- a/web/mkdocs/docs/getting-started.md +++ /dev/null @@ -1,41 +0,0 @@ -# Getting Started - -First of all, you need to install the Android SMS Gateway app on your device as described in the [Installation](installation.md). - -The Android SMS Gateway can work in two modes: with a local server started on the device or with a cloud server at [sms.capcom.me](https://sms.capcom.me). The basic API is the same for both modes and is documented on the [Android SMS Gateway API Documentation](https://capcom6.github.io/android-sms-gateway/). - -## Local server - -