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 - -
- Local server example settings -
- -This mode is recommended for sending messages from local network. - -1. Start the app on the device. -2. Activate the `Local server` switch. -3. Tap the `Offline` button at the bottom of the screen. -4. In the `Local server` section, the local and public addresses of the device, along with the credentials for basic authentication, will be displayed. Please note that the public address is only usable if you have a "white" IP address and have correctly configured your firewall. -5. Make a `curl` call from the local network using a command like the following, replacing ``, ``, and `` with the values obtained in step 4: - - ``` - curl -X POST -u : -H "Content-Type: application/json" -d '{ "message": "Hello, world!", "phoneNumbers": ["79990001234", "79995556677"] }' http://:8080/message - ``` - -## Cloud server - -
- Cloud server example settings -
- -If you need to send messages with dynamic or shared device IP, you can use the cloud server. The best part? No registration, email, or phone number is required to start using it. - -1. Start the app on the device. -2. Activate the `Cloud server` switch. -3. Tap the `Offline` button at the bottom of the screen. -4. In the `Cloud server` section, the credentials for basic authentication will be displayed. -5. Make a curl call using a command like the following, replacing `` and `` with the values obtained in step 4: - - ``` - curl -X POST -u : -H "Content-Type: application/json" -d '{ "message": "Hello, world!", "phoneNumbers": ["79990001234", "79995556677"] }' https://sms.capcom.me/api/3rdparty/v1/message - ``` diff --git a/web/mkdocs/docs/getting-started/index.md b/web/mkdocs/docs/getting-started/index.md new file mode 100644 index 0000000..79203a7 --- /dev/null +++ b/web/mkdocs/docs/getting-started/index.md @@ -0,0 +1,11 @@ +# Getting Started + +The Android SMS Gateway can operate in three distinct modes, all using the same API: + +1. [**Local Server**](./local-server.md): Operate entirely within your local network by running the server directly on your Android device. This is perfect for a quick setup and use where Internet access isn't necessary. For remote access, you may need to adjust your network settings and consider additional security measures. +2. [**Public Cloud Server**](./public-cloud-server.md): Connect easily via the Internet using our public server at `sms.capcom.me`. Messages are routed through this server to your devices, which simplifies the setup without the need for network adjustments. This is suitable for non-sensitive data only — for more secure communication, please check out the [end-to-end encryption section](../privacy/encryption.md). +3. [**Private Server**](./private-server.md): Deploy your own server instance and connect your Android devices to ensure maximum privacy. We won't have access to your message content, making this the preferred option for sensitive communication. However, it requires setting up and maintaining your own infrastructure, which includes a database and server application. + +For any of these modes, you'll first need to install the Android SMS Gateway app on your device as described in the [Installation](../installation.md) section. + +For more information on how to use the API, please refer to the [API](../api.md) section. diff --git a/web/mkdocs/docs/getting-started/local-server.md b/web/mkdocs/docs/getting-started/local-server.md new file mode 100644 index 0000000..7393af5 --- /dev/null +++ b/web/mkdocs/docs/getting-started/local-server.md @@ -0,0 +1,22 @@ +# Getting Started + +## Local Server + +
+ Architecture of the Local Server mode +
+ +This mode is ideal for sending messages from a local network. + +1. Launch the app on your device. +2. Toggle the `Local Server` switch to the "on" position. +3. Tap the `Offline` button located at the bottom of the screen to activate the server. +4. The `Local Server` section will display your device's local and public IP addresses, as well as the credentials for basic authentication. Please note that the public IP address is only accessible if you have a public (also known as "white") IP and your firewall is configured appropriately. +
+ Example settings for Local Server mode +
+5. To send a message from within the local network, execute a `curl` command like the following. Be sure to replace ``, ``, and `` with the actual values provided in the previous step: + + ```sh + curl -X POST -u : -H "Content-Type: application/json" -d '{ "message": "Hello, world!", "phoneNumbers": ["79990001234", "79995556677"] }' http://:8080/message + ``` diff --git a/web/mkdocs/docs/getting-started/private-server.md b/web/mkdocs/docs/getting-started/private-server.md new file mode 100644 index 0000000..ad0d2d9 --- /dev/null +++ b/web/mkdocs/docs/getting-started/private-server.md @@ -0,0 +1,44 @@ +# Getting Started + +## Private Server + +
+ Architecture of the Private Server mode +
+ +To enhance privacy, you can host a private server within your own infrastructure, ensuring that all messages remain solely on devices you control. The only required external network connection is for sending push notifications via the public server at `sms.capcom.me`. This setup eliminates the need to configure Firebase Cloud Messaging (FCM) or rebuild the Android app, but it does demand some technical know-how. + +### Prerequisites + +- A MySQL or MariaDB database server with an empty database, and a user granted full access to that database. +- A Virtual Private Server (VPS) running Linux with Docker installed. +- A reverse proxy with a valid SSL certificate and HTTPS enabled. + +### Run the Server + +1. Create a `config.yml` file based on the template provided in [config.example.yml](https://github.com/capcom6/sms-gateway/blob/master/configs/config.example.yml). Pay special attention to the `database`, `http`, and `gateway` sections. Environment variables can be used to override values in the config file. + - Set `gateway.mode` to `private`. + - Define `gateway.private_token` as the access token for device registration in private mode. Ensure this token matches the one on the devices set to private mode. +2. Start the server in Docker with the following command: + ```sh + docker run -p 3000:3000 -v $(pwd)/config.yml:/app/config.yml capcom6/sms-gateway:latest + ``` +3. Configure your reverse proxy, enable SSL, and modify your firewall settings to permit Internet access to the server. + +Refer to the server's [README.md](https://github.com/capcom6/sms-gateway/blob/master/README.md) for more information. + +### Configure the Android App + +
+ Example settings for Private Server mode +
+ +*Note*: Changing the server will invalidate current credentials, and the device will be re-registered with new ones. + +1. Navigate to the Settings tab. +2. In the Cloud Server section, enter the API URL and Private token, ensuring they match those in the server configuration. Note that you should include the full URL with the path, such as `https://private.example.com/api/mobile/v1`. +3. Switch to the Home tab. +4. Activate the Cloud server option. +5. Apply the new configuration by stopping and starting the app using the button at the bottom of the screen. + +If everything is configured correctly, the new credentials for the private server will be displayed in the Cloud Server section on the Home tab. diff --git a/web/mkdocs/docs/getting-started/public-cloud-server.md b/web/mkdocs/docs/getting-started/public-cloud-server.md new file mode 100644 index 0000000..4a3a36f --- /dev/null +++ b/web/mkdocs/docs/getting-started/public-cloud-server.md @@ -0,0 +1,22 @@ +# Getting Started + +## Cloud Server + +
+ Architecture of the Cloud Server mode +
+ +Use the cloud server mode when dealing with dynamic or shared device IP addresses. The best part? No registration, email, or phone number is required to start using it. + +1. Launch the app on your device. +2. Toggle the `Cloud Server` switch to the "on" position. +3. Tap the `Online` button located at the bottom of the screen to connect to the cloud server. +4. In the `Cloud Server` section, the credentials for basic authentication will be displayed. +
+ Example settings for Cloud Server mode +
+5. To send a message via the cloud server, perform a `curl` request with a command similar to the following, substituting `` and `` with the actual values obtained in step 4: + + ```sh + curl -X POST -u : -H "Content-Type: application/json" -d '{ "message": "Hello, world!", "phoneNumbers": ["79990001234", "79995556677"] }' https://sms.capcom.me/api/3rdparty/v1/message + ``` diff --git a/web/mkdocs/docs/index.md b/web/mkdocs/docs/index.md index e6cfa0b..9928f7c 100644 --- a/web/mkdocs/docs/index.md +++ b/web/mkdocs/docs/index.md @@ -17,6 +17,9 @@ Android SMS Gateway turns your Android smartphone into an SMS gateway. It's a li - **Starts at boot:** The application starts running as soon as your device boots up. - **Multiple SIM cards:** The application supports multiple SIM cards. - **Multipart messages:** The application supports sending long messages with auto-partitioning. +- **End-to-end encryption:** The application supports end-to-end encryption by encrypting message text and recipients' phone numbers before sending them to the API and decrypting them on the device. +- **Messages expiration:** The application supports setting messages' expiration time. The messages will not be sent if they are expired. +- **Random delay between sending messages:** To avoid mobile operator restrictions. ## Ideal For @@ -34,7 +37,7 @@ The project is currently in the MVP stage. We're actively working on adding more Getting started with Android SMS Gateway is easy and straightforward. No registration, email, or phone number is required to create an account and start using the app. -Check out our [Getting Started Guide](getting-started.md) to learn how to install and use Android SMS Gateway. +Check out our [Getting Started Guide](getting-started/index.md) to learn how to install and use Android SMS Gateway. ## Contributing diff --git a/web/mkdocs/mkdocs.yml b/web/mkdocs/mkdocs.yml index 5d9217d..9e367f0 100644 --- a/web/mkdocs/mkdocs.yml +++ b/web/mkdocs/mkdocs.yml @@ -29,7 +29,11 @@ theme: nav: - Home: index.md - Installation: installation.md - - Getting Started: getting-started.md + - Getting Started: + - Overview: getting-started/index.md + - Local Server: getting-started/local-server.md + - Public Cloud Server: getting-started/public-cloud-server.md + - Private Server: getting-started/private-server.md - API: api.md - Pricing: pricing.md - Privacy: