Merge pull request #69 from capcom6/feature/webhooks

Add webhooks support
This commit is contained in:
Aleksandr 2024-06-18 14:17:46 +07:00 committed by GitHub
commit 86178005f0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 1385 additions and 236 deletions

View File

@ -57,3 +57,23 @@ Authorization: Basic {{localCredentials}}
###
GET {{localUrl}}/message/8GN2Pz-fzu73NL3398ROE HTTP/1.1
Authorization: Basic {{localCredentials}}
###
GET {{localUrl}}/webhooks HTTP/1.1
Authorization: Basic {{localCredentials}}
###
POST {{localUrl}}/webhooks HTTP/1.1
Authorization: Basic {{localCredentials}}
Content-Type: application/json
{
"id": "LreFUt-Z3sSq0JufY9uWB",
"url": "https://webhook.site/280a6655-eb68-40b9-b857-af5be37c5303",
"event": "sms:received"
}
###
DELETE {{localUrl}}/webhooks/LreFUt-Z3sSq0JufY9uWB HTTP/1.1
Authorization: Basic {{localCredentials}}

43
api/mobile.http Normal file
View File

@ -0,0 +1,43 @@
@baseUrl={{$dotenv CLOUD__URL}}/api/mobile/v1
@mobileToken={{$dotenv MOBILE__TOKEN}}
@phone={{$dotenv PHONE}}
###
POST {{baseUrl}}/device HTTP/1.1
Authorization: Bearer 123456789
Content-Type: application/json
{
"name": "Android Phone",
"pushToken": "eTxx88nfSla87gZuJcW5mS:APA91bHGxVgSqqRtxwFHD1q9em5Oa6xSP4gO_OZRrqOoP1wjf_7UMfXKsc4uws6rWkqn73jYCc1owyATB1v61mqak4ntpqtmRkNtTey7NQXa0Wz3uQZBWY-Ecbn2rWG2VJRihOzXRId-"
}
###
GET {{baseUrl}}/message HTTP/1.1
Authorization: Bearer {{mobileToken}}
###
PATCH {{baseUrl}}/message HTTP/1.1
Authorization: Bearer {{mobileToken}}
Content-Type: application/json
[
{
"id": "2dcIAhcLg81cez7GE_Pdp",
"state": "Failed",
"recipients": [
{
"phoneNumber": "{{phone}}",
"state": "Failed"
}
],
"states": {
"Processed": "2024-05-13T16:49:17.357+07:00",
"Failed": "2024-05-13T16:49:17.357+07:00"
}
}
]
###
GET {{baseUrl}}/webhooks HTTP/1.1
Authorization: Bearer {{mobileToken}}

View File

@ -9,16 +9,6 @@ GET {{baseUrl}}/health HTTP/1.1
###
GET {{baseUrl}}/api/3rdparty/v1/health HTTP/1.1
###
POST {{baseUrl}}/api/mobile/v1/device HTTP/1.1
Authorization: Bearer 123456789
Content-Type: application/json
{
"name": "Android Phone",
"pushToken": "eTxx88nfSla87gZuJcW5mS:APA91bHGxVgSqqRtxwFHD1q9em5Oa6xSP4gO_OZRrqOoP1wjf_7UMfXKsc4uws6rWkqn73jYCc1owyATB1v61mqak4ntpqtmRkNtTey7NQXa0Wz3uQZBWY-Ecbn2rWG2VJRihOzXRId-"
}
###
POST {{baseUrl}}/api/3rdparty/v1/message?skipPhoneValidation=false HTTP/1.1
Content-Type: application/json
@ -58,32 +48,6 @@ Authorization: Basic {{credentials}}
GET {{baseUrl}}/api/3rdparty/v1/device HTTP/1.1
Authorization: Basic {{credentials}}
###
GET {{baseUrl}}/api/mobile/v1/message HTTP/1.1
Authorization: Bearer {{mobileToken}}
###
PATCH {{baseUrl}}/api/mobile/v1/message HTTP/1.1
Authorization: Bearer {{mobileToken}}
Content-Type: application/json
[
{
"id": "2dcIAhcLg81cez7GE_Pdp",
"state": "Failed",
"recipients": [
{
"phoneNumber": "{{phone}}",
"state": "Failed"
}
],
"states": {
"Processed": "2024-05-13T16:49:17.357+07:00",
"Failed": "2024-05-13T16:49:17.357+07:00"
}
}
]
###
POST {{baseUrl}}/api/upstream/v1/push HTTP/1.1
Content-Type: application/json
@ -92,4 +56,24 @@ Content-Type: application/json
{
"token": "eTxx88nfSla87gZuJcW5mS:APA91bHGxVgSqqRtxwFHD1q9em5Oa6xSP4gO_OZRrqOoP1wjf_7UMfXKsc4uws6rWkqn73jYCc1owyATB1v61mqak4ntpqtmRkNtTey7NQXa0Wz3uQZBWY-Ecbn2rWG2VJRihOzXRId-"
}
]
]
###
GET {{baseUrl}}/api/3rdparty/v1/webhooks HTTP/1.1
Authorization: Basic {{credentials}}
###
POST {{baseUrl}}/api/3rdparty/v1/webhooks HTTP/1.1
Authorization: Basic {{credentials}}
Content-Type: application/json
{
"id": "MYofX8bTd5Bov0wWFZLRP",
"url": "https://webhook.site/280a6655-eb68-40b9-b857-af5be37c5303",
"event": "sms:received"
}
###
DELETE {{baseUrl}}/api/3rdparty/v1/webhooks/MYofX8bTd5Bov0wWFZLRP HTTP/1.1
Authorization: Basic {{credentials}}

View File

@ -208,6 +208,150 @@
}
}
},
"/api/v1/3rdparty/webhooks": {
"get": {
"security": [
{
"ApiAuth": []
}
],
"description": "Returns list of registered webhooks",
"produces": [
"application/json"
],
"tags": [
"User",
"Webhooks"
],
"summary": "List webhooks",
"responses": {
"200": {
"description": "Webhook list",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/smsgateway.Webhook"
}
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/smsgateway.ErrorResponse"
}
},
"500": {
"description": "Internal server error",
"schema": {
"$ref": "#/definitions/smsgateway.ErrorResponse"
}
}
}
},
"post": {
"security": [
{
"ApiAuth": []
}
],
"description": "Registers webhook. If webhook with same ID already exists, it will be replaced",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"User",
"Webhooks"
],
"summary": "Register webhook",
"parameters": [
{
"description": "Webhook",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/smsgateway.Webhook"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/smsgateway.Webhook"
}
},
"400": {
"description": "Invalid request",
"schema": {
"$ref": "#/definitions/smsgateway.ErrorResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/smsgateway.ErrorResponse"
}
},
"500": {
"description": "Internal server error",
"schema": {
"$ref": "#/definitions/smsgateway.ErrorResponse"
}
}
}
}
},
"/api/v1/3rdparty/webhooks/{id}": {
"delete": {
"security": [
{
"ApiAuth": []
}
],
"description": "Deletes webhook",
"produces": [
"application/json"
],
"tags": [
"User",
"Webhooks"
],
"summary": "Delete webhook",
"parameters": [
{
"type": "string",
"description": "Webhook ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "Webhook deleted",
"schema": {
"type": "object"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/smsgateway.ErrorResponse"
}
},
"500": {
"description": "Internal server error",
"schema": {
"$ref": "#/definitions/smsgateway.ErrorResponse"
}
}
}
}
},
"/health": {
"get": {
"description": "Checks if service is healthy",
@ -429,6 +573,47 @@
}
}
},
"/mobile/v1/webhooks": {
"get": {
"security": [
{
"MobileToken": []
}
],
"description": "Returns list of registered webhooks for device",
"produces": [
"application/json"
],
"tags": [
"Device",
"Webhooks"
],
"summary": "List webhooks",
"responses": {
"200": {
"description": "Webhook list",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/smsgateway.Webhook"
}
}
},
"401": {
"description": "Unauthorized",
"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",
@ -799,14 +984,46 @@
"ProcessingStateFailed"
]
},
"smsgateway.PushEventType": {
"type": "string",
"enum": [
"MessageEnqueued",
"WebhooksUpdated"
],
"x-enum-varnames": [
"PushMessageEnqueued",
"PushWebhooksUpdated"
]
},
"smsgateway.PushNotification": {
"type": "object",
"required": [
"token"
],
"properties": {
"data": {
"description": "The additional data associated with the event.",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"event": {
"description": "The type of event.",
"default": "MessageEnqueued",
"enum": [
"MessageEnqueued",
"WebhooksUpdated"
],
"allOf": [
{
"$ref": "#/definitions/smsgateway.PushEventType"
}
],
"example": "MessageEnqueued"
},
"token": {
"description": "Device FCM token",
"description": "The token of the device that receives the notification.",
"type": "string",
"example": "PyDmBQZZXYmyxMwED8Fzy"
}
@ -841,6 +1058,44 @@
"example": "Pending"
}
}
},
"smsgateway.Webhook": {
"type": "object",
"required": [
"event",
"url"
],
"properties": {
"event": {
"description": "The type of event the webhook is triggered for.",
"allOf": [
{
"$ref": "#/definitions/smsgateway.WebhookEvent"
}
],
"example": "sms:received"
},
"id": {
"description": "The unique identifier of the webhook.",
"type": "string",
"maxLength": 36,
"example": "123e4567-e89b-12d3-a456-426614174000"
},
"url": {
"description": "The URL the webhook will be sent to.",
"type": "string",
"example": "https://example.com/webhook"
}
}
},
"smsgateway.WebhookEvent": {
"type": "string",
"enum": [
"sms:received"
],
"x-enum-varnames": [
"WebhookEventSmsReceived"
]
}
},
"securityDefinitions": {

View File

@ -238,10 +238,32 @@ definitions:
- ProcessingStateSent
- ProcessingStateDelivered
- ProcessingStateFailed
smsgateway.PushEventType:
enum:
- MessageEnqueued
- WebhooksUpdated
type: string
x-enum-varnames:
- PushMessageEnqueued
- PushWebhooksUpdated
smsgateway.PushNotification:
properties:
data:
additionalProperties:
type: string
description: The additional data associated with the event.
type: object
event:
allOf:
- $ref: '#/definitions/smsgateway.PushEventType'
default: MessageEnqueued
description: The type of event.
enum:
- MessageEnqueued
- WebhooksUpdated
example: MessageEnqueued
token:
description: Device FCM token
description: The token of the device that receives the notification.
example: PyDmBQZZXYmyxMwED8Fzy
type: string
required:
@ -268,6 +290,32 @@ definitions:
- phoneNumber
- state
type: object
smsgateway.Webhook:
properties:
event:
allOf:
- $ref: '#/definitions/smsgateway.WebhookEvent'
description: The type of event the webhook is triggered for.
example: sms:received
id:
description: The unique identifier of the webhook.
example: 123e4567-e89b-12d3-a456-426614174000
maxLength: 36
type: string
url:
description: The URL the webhook will be sent to.
example: https://example.com/webhook
type: string
required:
- event
- url
type: object
smsgateway.WebhookEvent:
enum:
- sms:received
type: string
x-enum-varnames:
- WebhookEventSmsReceived
host: sms.capcom.me
info:
contact:
@ -402,6 +450,99 @@ paths:
summary: Health check
tags:
- System
/api/v1/3rdparty/webhooks:
get:
description: Returns list of registered webhooks
produces:
- application/json
responses:
"200":
description: Webhook list
schema:
items:
$ref: '#/definitions/smsgateway.Webhook'
type: array
"401":
description: Unauthorized
schema:
$ref: '#/definitions/smsgateway.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/smsgateway.ErrorResponse'
security:
- ApiAuth: []
summary: List webhooks
tags:
- User
- Webhooks
post:
consumes:
- application/json
description: Registers webhook. If webhook with same ID already exists, it will
be replaced
parameters:
- description: Webhook
in: body
name: request
required: true
schema:
$ref: '#/definitions/smsgateway.Webhook'
produces:
- application/json
responses:
"201":
description: Created
schema:
$ref: '#/definitions/smsgateway.Webhook'
"400":
description: Invalid request
schema:
$ref: '#/definitions/smsgateway.ErrorResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/smsgateway.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/smsgateway.ErrorResponse'
security:
- ApiAuth: []
summary: Register webhook
tags:
- User
- Webhooks
/api/v1/3rdparty/webhooks/{id}:
delete:
description: Deletes webhook
parameters:
- description: Webhook ID
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"204":
description: Webhook deleted
schema:
type: object
"401":
description: Unauthorized
schema:
$ref: '#/definitions/smsgateway.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/smsgateway.ErrorResponse'
security:
- ApiAuth: []
summary: Delete webhook
tags:
- User
- Webhooks
/health:
get:
description: Checks if service is healthy
@ -544,6 +685,32 @@ paths:
tags:
- Device
- Messages
/mobile/v1/webhooks:
get:
description: Returns list of registered webhooks for device
produces:
- application/json
responses:
"200":
description: Webhook list
schema:
items:
$ref: '#/definitions/smsgateway.Webhook'
type: array
"401":
description: Unauthorized
schema:
$ref: '#/definitions/smsgateway.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/smsgateway.ErrorResponse'
security:
- MobileToken: []
summary: List webhooks
tags:
- Device
- Webhooks
/upstream/v1/push:
post:
consumes:

2
go.mod
View File

@ -4,7 +4,7 @@ go 1.22.0
require (
firebase.google.com/go/v4 v4.12.1
github.com/android-sms-gateway/client-go v1.0.0
github.com/android-sms-gateway/client-go v1.0.1-0.20240610222412-894fc9370287
github.com/capcom6/go-helpers v0.0.0-20240521035030-5f57bddeecee
github.com/capcom6/go-infra-fx v0.0.2
github.com/go-playground/validator/v10 v10.16.0

4
go.sum
View File

@ -32,6 +32,10 @@ github.com/android-sms-gateway/client-go v0.0.0-20240530135354-8d1ce85b9734 h1:d
github.com/android-sms-gateway/client-go v0.0.0-20240530135354-8d1ce85b9734/go.mod h1:DQsReciU1xcaVW3T5Z2bqslNdsAwCFCtghawmA6g6L4=
github.com/android-sms-gateway/client-go v1.0.0 h1:TPRNHlgcEW6jThsx0y4AG1J7wH5Iry+c6h+ailrSQW4=
github.com/android-sms-gateway/client-go v1.0.0/go.mod h1:DQsReciU1xcaVW3T5Z2bqslNdsAwCFCtghawmA6g6L4=
github.com/android-sms-gateway/client-go v1.0.1-0.20240610220902-94dc5641aa00 h1:GUtH5Pw57cxhpQ3y8EYQFTqbpjQ2dZR6G7BjcHK6lAM=
github.com/android-sms-gateway/client-go v1.0.1-0.20240610220902-94dc5641aa00/go.mod h1:DQsReciU1xcaVW3T5Z2bqslNdsAwCFCtghawmA6g6L4=
github.com/android-sms-gateway/client-go v1.0.1-0.20240610222412-894fc9370287 h1:4Q6TuWQcTrKb+nyMrdBTBIV0b4R/xQgmOJhOygHWIkg=
github.com/android-sms-gateway/client-go v1.0.1-0.20240610222412-894fc9370287/go.mod h1:DQsReciU1xcaVW3T5Z2bqslNdsAwCFCtghawmA6g6L4=
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=

View File

@ -12,11 +12,13 @@ import (
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"
appdb "github.com/capcom6/sms-gateway/internal/sms-gateway/modules/db"
"github.com/capcom6/sms-gateway/internal/sms-gateway/modules/devices"
"github.com/capcom6/sms-gateway/internal/sms-gateway/modules/health"
"github.com/capcom6/sms-gateway/internal/sms-gateway/modules/messages"
"github.com/capcom6/sms-gateway/internal/sms-gateway/modules/push"
"github.com/capcom6/sms-gateway/internal/sms-gateway/modules/webhooks"
"github.com/capcom6/sms-gateway/internal/sms-gateway/repositories"
"github.com/capcom6/sms-gateway/internal/sms-gateway/services"
"go.uber.org/fx"
"go.uber.org/fx/fxevent"
"go.uber.org/zap"
@ -27,16 +29,18 @@ var Module = fx.Module(
"server",
logger.Module,
appconfig.Module,
appdb.Module,
http.Module,
validator.Module,
handlers.Module,
services.Module,
auth.Module,
push.Module,
repositories.Module,
db.Module,
messages.Module,
health.Module,
webhooks.Module,
devices.Module,
)
func Run() {

View File

@ -5,11 +5,13 @@ import (
"fmt"
"github.com/android-sms-gateway/client-go/smsgateway"
"github.com/capcom6/sms-gateway/internal/sms-gateway/handlers/base"
"github.com/capcom6/sms-gateway/internal/sms-gateway/handlers/webhooks"
"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/modules/devices"
"github.com/capcom6/sms-gateway/internal/sms-gateway/modules/messages"
"github.com/capcom6/sms-gateway/internal/sms-gateway/repositories"
"github.com/capcom6/sms-gateway/internal/sms-gateway/services"
"github.com/capcom6/sms-gateway/pkg/types"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
@ -25,24 +27,26 @@ const (
type ThirdPartyHandlerParams struct {
fx.In
HealthHandler *healthHandler
HealthHandler *healthHandler
WebhooksHandler *webhooks.ThirdPartyController
AuthSvc *auth.Service
MessagesSvc *messages.Service
DevicesSvc *services.DevicesService
DevicesSvc *devices.Service
Logger *zap.Logger
Validator *validator.Validate
}
type thirdPartyHandler struct {
Handler
base.Handler
healthHandler *healthHandler
healthHandler *healthHandler
webhooksHandler *webhooks.ThirdPartyController
authSvc *auth.Service
messagesSvc *messages.Service
devicesSvc *services.DevicesService
devicesSvc *devices.Service
}
// @Summary List devices
@ -58,7 +62,7 @@ type thirdPartyHandler struct {
//
// List devices
func (h *thirdPartyHandler) getDevice(user models.User, c *fiber.Ctx) error {
devices, err := h.devicesSvc.Select(user)
devices, err := h.devicesSvc.Select(devices.WithUserID(user.ID))
if err != nil {
return fmt.Errorf("can't select devices: %w", err)
}
@ -106,7 +110,7 @@ func (h *thirdPartyHandler) postMessage(user models.User, c *fiber.Ctx) error {
skipPhoneValidation := c.QueryBool("skipPhoneValidation", false)
devices, err := h.devicesSvc.Select(user)
devices, err := h.devicesSvc.Select(devices.WithUserID(user.ID))
if err != nil {
return fmt.Errorf("can't select devices: %w", err)
}
@ -166,8 +170,16 @@ func (h *thirdPartyHandler) getMessage(user models.User, c *fiber.Ctx) error {
return c.JSON(state)
}
func (h *thirdPartyHandler) authorize(handler func(models.User, *fiber.Ctx) error) fiber.Handler {
return func(c *fiber.Ctx) error {
func (h *thirdPartyHandler) Register(router fiber.Router) {
router = router.Group("/3rdparty/v1")
h.healthHandler.Register(router)
router.Use(basicauth.New(basicauth.Config{
Authorizer: func(username string, password string) bool {
return len(username) > 0 && len(password) > 0
},
}), func(c *fiber.Ctx) error {
username := c.Locals("username").(string)
password := c.Locals("password").(string)
@ -179,33 +191,24 @@ func (h *thirdPartyHandler) authorize(handler func(models.User, *fiber.Ctx) erro
c.Locals("user", user)
return handler(user, c)
}
}
return c.Next()
})
func (h *thirdPartyHandler) Register(router fiber.Router) {
router = router.Group("/3rdparty/v1")
router.Get("/device", auth.WithUser(h.getDevice))
h.healthHandler.Register(router)
router.Post("/message", auth.WithUser(h.postMessage))
router.Get("/message/:id", auth.WithUser(h.getMessage)).Name(route3rdPartyGetMessage)
router.Use(basicauth.New(basicauth.Config{
Authorizer: func(username string, password string) bool {
return len(username) > 0 && len(password) > 0
},
}))
router.Get("/device", h.authorize(h.getDevice))
router.Post("/message", h.authorize(h.postMessage))
router.Get("/message/:id", h.authorize(h.getMessage)).Name(route3rdPartyGetMessage)
h.webhooksHandler.Register(router.Group("/webhooks"))
}
func newThirdPartyHandler(params ThirdPartyHandlerParams) *thirdPartyHandler {
return &thirdPartyHandler{
Handler: Handler{Logger: params.Logger.Named("ThirdPartyHandler"), Validator: params.Validator},
healthHandler: params.HealthHandler,
authSvc: params.AuthSvc,
messagesSvc: params.MessagesSvc,
devicesSvc: params.DevicesSvc,
Handler: base.Handler{Logger: params.Logger.Named("ThirdPartyHandler"), Validator: params.Validator},
healthHandler: params.HealthHandler,
webhooksHandler: params.WebhooksHandler,
authSvc: params.AuthSvc,
messagesSvc: params.MessagesSvc,
devicesSvc: params.DevicesSvc,
}
}

View File

@ -1,4 +1,4 @@
package handlers
package base
import (
"fmt"
@ -22,7 +22,7 @@ func (h *Handler) BodyParserValidator(c *fiber.Ctx, out any) error {
return fmt.Errorf("can't parse body: %w", err)
}
return h.validateStruct(out)
return h.ValidateStruct(out)
}
func (h *Handler) QueryParserValidator(c *fiber.Ctx, out any) error {
@ -30,7 +30,7 @@ func (h *Handler) QueryParserValidator(c *fiber.Ctx, out any) error {
return fmt.Errorf("can't parse query: %w", err)
}
return h.validateStruct(out)
return h.ValidateStruct(out)
}
func (h *Handler) ParamsParserValidator(c *fiber.Ctx, out any) error {
@ -38,10 +38,10 @@ func (h *Handler) ParamsParserValidator(c *fiber.Ctx, out any) error {
return fmt.Errorf("can't parse params: %w", err)
}
return h.validateStruct(out)
return h.ValidateStruct(out)
}
func (h *Handler) validateStruct(out any) error {
func (h *Handler) ValidateStruct(out any) error {
if h.Validator != nil {
if err := h.Validator.Struct(out); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())

View File

@ -1,4 +1,4 @@
package handlers
package base
import (
"bytes"
@ -197,7 +197,7 @@ func TestHandler_validateStruct(t *testing.T) {
Logger: tt.fields.Logger,
Validator: tt.fields.Validator,
}
if err := h.validateStruct(tt.args.out); (err != nil) != tt.wantErr {
if err := h.ValidateStruct(tt.args.out); (err != nil) != tt.wantErr {
t.Errorf("Handler.validateStruct() error = %v, wantErr %v", err, tt.wantErr)
}
})

View File

@ -2,6 +2,7 @@ package handlers
import (
"github.com/android-sms-gateway/client-go/smsgateway"
"github.com/capcom6/sms-gateway/internal/sms-gateway/handlers/base"
"github.com/capcom6/sms-gateway/internal/sms-gateway/modules/health"
"github.com/capcom6/sms-gateway/internal/version"
"github.com/capcom6/sms-gateway/pkg/maps"
@ -19,7 +20,7 @@ type healthHanlderParams struct {
}
type healthHandler struct {
Handler
base.Handler
healthSvc *health.Service
@ -72,7 +73,7 @@ func (h *healthHandler) Register(router fiber.Router) {
func newHealthHandler(params healthHanlderParams) *healthHandler {
return &healthHandler{
Handler: Handler{Logger: params.Logger.Named("HealthHandler"), Validator: nil},
Handler: base.Handler{Logger: params.Logger.Named("HealthHandler"), Validator: nil},
healthSvc: params.HealthSvc,
logger: params.Logger,
}

View File

@ -7,6 +7,8 @@ import (
"github.com/android-sms-gateway/client-go/smsgateway"
"github.com/capcom6/go-infra-fx/http/apikey"
"github.com/capcom6/sms-gateway/internal/sms-gateway/handlers/base"
"github.com/capcom6/sms-gateway/internal/sms-gateway/handlers/webhooks"
"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/modules/messages"
@ -20,11 +22,13 @@ import (
)
type mobileHandler struct {
Handler
base.Handler
authSvc *auth.Service
messagesSvc *messages.Service
webhooksCtrl *webhooks.MobileController
idGen func() string
}
@ -58,7 +62,7 @@ func (h *mobileHandler) postDevice(c *fiber.Ctx) error {
return fmt.Errorf("can't create user: %w", err)
}
device, err := h.authSvc.RegisterDevice(user.ID, req.Name, req.PushToken)
device, err := h.authSvc.RegisterDevice(user, req.Name, req.PushToken)
if err != nil {
return fmt.Errorf("can't register device: %w", err)
}
@ -142,7 +146,7 @@ func (h *mobileHandler) patchMessage(device models.Device, c *fiber.Ctx) error {
}
for _, v := range req {
if err := h.validateStruct(v); err != nil {
if err := h.ValidateStruct(v); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
@ -155,20 +159,6 @@ func (h *mobileHandler) patchMessage(device models.Device, c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent)
}
func (h *mobileHandler) authorize(handler func(models.Device, *fiber.Ctx) error) fiber.Handler {
return func(c *fiber.Ctx) error {
token := c.Locals("token").(string)
device, err := h.authSvc.AuthorizeDevice(token)
if err != nil {
h.Logger.Error("Can't authorize device", zap.Error(err))
return fiber.ErrUnauthorized
}
return handler(device, c)
}
}
func (h *mobileHandler) Register(router fiber.Router) {
router = router.Group("/mobile/v1")
@ -183,15 +173,29 @@ func (h *mobileHandler) Register(router fiber.Router) {
Authorizer: func(token string) bool {
return len(token) > 0
},
}))
}), func(c *fiber.Ctx) error {
token := c.Locals("token").(string)
router.Patch("/device", h.authorize(h.patchDevice))
device, err := h.authSvc.AuthorizeDevice(token)
if err != nil {
h.Logger.Error("Can't authorize device", zap.Error(err))
return fiber.ErrUnauthorized
}
router.Get("/message", h.authorize(h.getMessage))
router.Patch("/message", h.authorize(h.patchMessage))
c.Locals("device", device)
return c.Next()
})
router.Patch("/device", auth.WithDevice(h.patchDevice))
router.Get("/message", auth.WithDevice(h.getMessage))
router.Patch("/message", auth.WithDevice(h.patchMessage))
h.webhooksCtrl.Register(router.Group("/webhooks"))
}
type MobileHandlerParams struct {
type mobileHandlerParams struct {
fx.In
Logger *zap.Logger
@ -199,15 +203,18 @@ type MobileHandlerParams struct {
AuthSvc *auth.Service
MessagesSvc *messages.Service
WebhooksCtrl *webhooks.MobileController
}
func newMobileHandler(params MobileHandlerParams) *mobileHandler {
func newMobileHandler(params mobileHandlerParams) *mobileHandler {
idGen, _ := nanoid.Standard(21)
return &mobileHandler{
Handler: Handler{Logger: params.Logger, Validator: params.Validator},
authSvc: params.AuthSvc,
messagesSvc: params.MessagesSvc,
idGen: idGen,
Handler: base.Handler{Logger: params.Logger, Validator: params.Validator},
authSvc: params.AuthSvc,
messagesSvc: params.MessagesSvc,
webhooksCtrl: params.WebhooksCtrl,
idGen: idGen,
}
}

View File

@ -2,6 +2,7 @@ package handlers
import (
"github.com/capcom6/go-infra-fx/http"
"github.com/capcom6/sms-gateway/internal/sms-gateway/handlers/webhooks"
"go.uber.org/fx"
"go.uber.org/zap"
)
@ -19,6 +20,8 @@ var Module = fx.Module(
),
fx.Provide(
newHealthHandler,
webhooks.NewThirdPartyController,
webhooks.NewMobileController,
fx.Private,
),
)

View File

@ -4,7 +4,9 @@ import (
"time"
"github.com/android-sms-gateway/client-go/smsgateway"
"github.com/capcom6/sms-gateway/internal/sms-gateway/handlers/base"
"github.com/capcom6/sms-gateway/internal/sms-gateway/modules/push"
"github.com/capcom6/sms-gateway/pkg/types"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/limiter"
@ -13,7 +15,7 @@ import (
)
type upstreamHandler struct {
Handler
base.Handler
config Config
pushSvc *push.Service
@ -31,7 +33,7 @@ type upstreamHandlerParams struct {
func newUpstreamHandler(params upstreamHandlerParams) *upstreamHandler {
return &upstreamHandler{
Handler: Handler{Logger: params.Logger, Validator: params.Validator},
Handler: base.Handler{Logger: params.Logger, Validator: params.Validator},
config: params.Config,
pushSvc: params.PushSvc,
}
@ -62,11 +64,16 @@ func (h *upstreamHandler) postPush(c *fiber.Ctx) error {
}
for _, v := range req {
if err := h.validateStruct(v); err != nil {
if err := h.ValidateStruct(v); err != nil {
return err
}
if err := h.pushSvc.Enqueue(c.Context(), v.Token, map[string]string{}); err != nil {
event := push.Event{
Event: types.ZeroDefault(v.Event, smsgateway.PushMessageEnqueued),
Data: v.Data,
}
if err := h.pushSvc.Enqueue(v.Token, &event); err != nil {
h.Logger.Error("Can't push message", zap.Error(err))
}
}

View File

@ -0,0 +1,116 @@
package webhooks
import (
"fmt"
"github.com/android-sms-gateway/client-go/smsgateway"
"github.com/capcom6/sms-gateway/internal/sms-gateway/handlers/base"
"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/modules/webhooks"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"go.uber.org/fx"
"go.uber.org/zap"
)
type thirdPartyControllerParams struct {
fx.In
WebhooksSvc *webhooks.Service
Validator *validator.Validate
Logger *zap.Logger
}
type ThirdPartyController struct {
base.Handler
webhooksSvc *webhooks.Service
}
// @Summary List webhooks
// @Description Returns list of registered webhooks
// @Security ApiAuth
// @Tags User, Webhooks
// @Produce json
// @Success 200 {object} []smsgateway.Webhook "Webhook list"
// @Failure 401 {object} smsgateway.ErrorResponse "Unauthorized"
// @Failure 500 {object} smsgateway.ErrorResponse "Internal server error"
// @Router /api/v1/3rdparty/webhooks [get]
//
// List webhooks
func (h *ThirdPartyController) get(user models.User, c *fiber.Ctx) error {
items, err := h.webhooksSvc.Select(user.ID)
if err != nil {
return fmt.Errorf("can't select webhooks: %w", err)
}
return c.JSON(items)
}
// @Summary Register webhook
// @Description Registers webhook. If webhook with same ID already exists, it will be replaced
// @Security ApiAuth
// @Tags User, Webhooks
// @Accept json
// @Produce json
// @Param request body smsgateway.Webhook true "Webhook"
// @Success 201 {object} smsgateway.Webhook "Created"
// @Failure 400 {object} smsgateway.ErrorResponse "Invalid request"
// @Failure 401 {object} smsgateway.ErrorResponse "Unauthorized"
// @Failure 500 {object} smsgateway.ErrorResponse "Internal server error"
// @Router /api/v1/3rdparty/webhooks [post]
//
// Register webhook
func (h *ThirdPartyController) post(user models.User, c *fiber.Ctx) error {
dto := &smsgateway.Webhook{}
if err := h.BodyParserValidator(c, dto); err != nil {
return err
}
if err := h.webhooksSvc.Replace(user.ID, dto); err != nil {
return fmt.Errorf("can't write webhook: %w", err)
}
return c.Status(fiber.StatusCreated).JSON(dto)
}
// @Summary Delete webhook
// @Description Deletes webhook
// @Security ApiAuth
// @Tags User, Webhooks
// @Produce json
// @Param id path string true "Webhook ID"
// @Success 204 {object} object "Webhook deleted"
// @Failure 401 {object} smsgateway.ErrorResponse "Unauthorized"
// @Failure 500 {object} smsgateway.ErrorResponse "Internal server error"
// @Router /api/v1/3rdparty/webhooks/{id} [delete]
//
// Delete webhook
func (h *ThirdPartyController) delete(user models.User, c *fiber.Ctx) error {
id := c.Params("id")
if err := h.webhooksSvc.Delete(user.ID, webhooks.WithExtID(id)); err != nil {
return fmt.Errorf("can't delete webhook: %w", err)
}
return c.SendStatus(fiber.StatusNoContent)
}
func (h *ThirdPartyController) Register(router fiber.Router) {
router.Get("/", auth.WithUser(h.get))
router.Post("/", auth.WithUser(h.post))
router.Delete("/:id", auth.WithUser(h.delete))
}
func NewThirdPartyController(params thirdPartyControllerParams) *ThirdPartyController {
return &ThirdPartyController{
Handler: base.Handler{
Logger: params.Logger.Named("controller"),
Validator: params.Validator,
},
webhooksSvc: params.WebhooksSvc,
}
}

View File

@ -0,0 +1,60 @@
package webhooks
import (
"fmt"
"github.com/capcom6/sms-gateway/internal/sms-gateway/handlers/base"
"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/modules/webhooks"
"github.com/gofiber/fiber/v2"
"go.uber.org/fx"
"go.uber.org/zap"
)
type mobileControllerParams struct {
fx.In
WebhooksServices *webhooks.Service
Logger *zap.Logger
}
type MobileController struct {
base.Handler
webhooksSvc *webhooks.Service
}
// @Summary List webhooks
// @Description Returns list of registered webhooks for device
// @Security MobileToken
// @Tags Device, Webhooks
// @Produce json
// @Success 200 {object} []smsgateway.Webhook "Webhook list"
// @Failure 401 {object} smsgateway.ErrorResponse "Unauthorized"
// @Failure 500 {object} smsgateway.ErrorResponse "Internal server error"
// @Router /mobile/v1/webhooks [get]
//
// List webhooks
func (h *MobileController) get(device models.Device, c *fiber.Ctx) error {
items, err := h.webhooksSvc.Select(device.UserID)
if err != nil {
return fmt.Errorf("can't select webhooks: %w", err)
}
return c.JSON(items)
}
func (h *MobileController) Register(router fiber.Router) {
router.Get("/", auth.WithDevice(h.get))
}
func NewMobileController(params mobileControllerParams) *MobileController {
return &MobileController{
Handler: base.Handler{
Logger: params.Logger.Named("mobile"),
},
webhooksSvc: params.WebhooksServices,
}
}

View File

@ -0,0 +1,21 @@
-- +goose Up
-- +goose StatementBegin
CREATE TABLE `webhooks` (
`id` BIGINT UNSIGNED AUTO_INCREMENT,
`ext_id` varchar(36) NOT NULL,
`user_id` varchar(32) NOT NULL,
`url` varchar(256) NOT NULL,
`event` varchar(32) NOT NULL,
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
`deleted_at` datetime(3) NULL,
PRIMARY KEY (`id`),
UNIQUE INDEX `unq_webhooks_user_extid` (`user_id`, `ext_id`),
CONSTRAINT `fk_webhooks_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
);
-- +goose StatementEnd
---
-- +goose Down
-- +goose StatementBegin
DROP TABLE `webhooks`;
-- +goose StatementEnd

View File

@ -15,9 +15,9 @@ const (
)
type TimedModel struct {
CreatedAt time.Time `gorm:"not null;autocreatetime:false;default:CURRENT_TIMESTAMP(3)"`
UpdatedAt time.Time `gorm:"not null;autoupdatetime:false;default:CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"`
DeletedAt *time.Time
CreatedAt time.Time `gorm:"->;not null;autocreatetime:false;default:CURRENT_TIMESTAMP(3)"`
UpdatedAt time.Time `gorm:"->;not null;autoupdatetime:false;default:CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"`
DeletedAt *time.Time `gorm:"<-:update"`
}
type User struct {

View File

@ -0,0 +1,18 @@
package auth
import (
"github.com/capcom6/sms-gateway/internal/sms-gateway/models"
"github.com/gofiber/fiber/v2"
)
func WithUser(handler func(models.User, *fiber.Ctx) error) fiber.Handler {
return func(c *fiber.Ctx) error {
return handler(c.Locals("user").(models.User), c)
}
}
func WithDevice(handler func(models.Device, *fiber.Ctx) error) fiber.Handler {
return func(c *fiber.Ctx) error {
return handler(c.Locals("device").(models.Device), c)
}
}

View File

@ -4,6 +4,7 @@ import (
"fmt"
"github.com/capcom6/sms-gateway/internal/sms-gateway/models"
"github.com/capcom6/sms-gateway/internal/sms-gateway/modules/devices"
"github.com/capcom6/sms-gateway/internal/sms-gateway/repositories"
"github.com/capcom6/sms-gateway/pkg/crypto"
"github.com/jaevor/go-nanoid"
@ -21,8 +22,8 @@ type Params struct {
Config Config
Users *repositories.UsersRepository
Devices *repositories.DevicesRepository
Users *repositories.UsersRepository
DevicesSvc *devices.Service
Logger *zap.Logger
}
@ -30,8 +31,8 @@ type Params struct {
type Service struct {
config Config
users *repositories.UsersRepository
devices *repositories.DevicesRepository
users *repositories.UsersRepository
devicesSvc *devices.Service
logger *zap.Logger
@ -42,11 +43,11 @@ 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,
config: params.Config,
users: params.Users,
devicesSvc: params.DevicesSvc,
logger: params.Logger.Named("Service"),
idgen: idgen,
}
}
@ -67,20 +68,17 @@ func (s *Service) RegisterUser(login, password string) (models.User, error) {
return user, nil
}
func (s *Service) RegisterDevice(userID string, name, pushToken *string) (models.Device, error) {
func (s *Service) RegisterDevice(user models.User, name, pushToken *string) (models.Device, error) {
device := models.Device{
ID: s.idgen(),
Name: name,
AuthToken: s.idgen(),
PushToken: pushToken,
UserID: userID,
}
return device, s.devices.Insert(&device)
return device, s.devicesSvc.Insert(user, &device)
}
func (s *Service) UpdateDevice(id, pushToken string) error {
return s.devices.UpdateToken(id, pushToken)
return s.devicesSvc.UpdateToken(id, pushToken)
}
func (s *Service) IsPublic() bool {
@ -100,12 +98,12 @@ func (s *Service) AuthorizeRegistration(token string) error {
}
func (s *Service) AuthorizeDevice(token string) (models.Device, error) {
device, err := s.devices.GetByToken(token)
device, err := s.devicesSvc.Get(devices.WithToken(token))
if err != nil {
return device, err
}
if err := s.devices.UpdateLastSeen(device.ID); err != nil {
if err := s.devicesSvc.UpdateLastSeen(device.ID); err != nil {
s.logger.Error("can't update last seen", zap.Error(err))
}

View File

@ -0,0 +1,15 @@
package db
import (
"github.com/jaevor/go-nanoid"
"go.uber.org/fx"
)
type IDGen func() string
var Module = fx.Module(
"db",
fx.Provide(func() (IDGen, error) {
return nanoid.Standard(21)
}),
)

View File

@ -1,4 +1,4 @@
package services
package devices
import (
"go.uber.org/fx"
@ -6,11 +6,15 @@ import (
)
var Module = fx.Module(
"services",
"devices",
fx.Decorate(func(log *zap.Logger) *zap.Logger {
return log.Named("services")
return log.Named("devices")
}),
fx.Provide(
NewDevicesService,
newDevicesRepository,
fx.Private,
),
fx.Provide(
NewService,
),
)

View File

@ -0,0 +1,65 @@
package devices
import (
"errors"
"time"
"github.com/capcom6/sms-gateway/internal/sms-gateway/models"
"gorm.io/gorm"
)
var (
ErrNotFound = gorm.ErrRecordNotFound
ErrInvalidFilter = errors.New("invalid filter")
ErrMoreThanOne = errors.New("more than one record")
)
type repository struct {
db *gorm.DB
}
func (r *repository) Select(filter ...SelectFilter) ([]models.Device, error) {
if len(filter) == 0 {
return nil, ErrInvalidFilter
}
f := newFilter(filter...)
devices := []models.Device{}
return devices, f.apply(r.db).Find(&devices).Error
}
func (r *repository) Get(filter ...SelectFilter) (models.Device, error) {
devices, err := r.Select(filter...)
if err != nil {
return models.Device{}, err
}
if len(devices) == 0 {
return models.Device{}, ErrNotFound
}
if len(devices) > 1 {
return models.Device{}, ErrMoreThanOne
}
return devices[0], nil
}
func (r *repository) Insert(device *models.Device) error {
return r.db.Create(device).Error
}
func (r *repository) UpdateToken(id, token string) error {
return r.db.Model(&models.Device{}).Where("id", id).Update("push_token", token).Error
}
func (r *repository) UpdateLastSeen(id string) error {
return r.db.Model(&models.Device{}).Where("id", id).Update("last_seen", time.Now()).Error
}
func newDevicesRepository(db *gorm.DB) *repository {
return &repository{
db: db,
}
}

View File

@ -0,0 +1,54 @@
package devices
import "gorm.io/gorm"
type SelectFilter func(*selectFilter)
func WithID(id string) SelectFilter {
return func(f *selectFilter) {
f.id = &id
}
}
func WithToken(token string) SelectFilter {
return func(f *selectFilter) {
f.token = &token
}
}
func WithUserID(userID string) SelectFilter {
return func(f *selectFilter) {
f.userID = &userID
}
}
type selectFilter struct {
id *string
userID *string
token *string
}
func newFilter(filters ...SelectFilter) *selectFilter {
f := &selectFilter{}
f.merge(filters...)
return f
}
func (f *selectFilter) merge(filters ...SelectFilter) {
for _, filter := range filters {
filter(f)
}
}
func (f *selectFilter) apply(query *gorm.DB) *gorm.DB {
if f.id != nil {
query = query.Where("id = ?", *f.id)
}
if f.token != nil {
query = query.Where("auth_token = ?", *f.token)
}
if f.userID != nil {
query = query.Where("user_id = ?", *f.userID)
}
return query
}

View File

@ -0,0 +1,58 @@
package devices
import (
"github.com/capcom6/sms-gateway/internal/sms-gateway/models"
"github.com/capcom6/sms-gateway/internal/sms-gateway/modules/db"
"go.uber.org/fx"
"go.uber.org/zap"
)
type ServiceParams struct {
fx.In
Devices *repository
IDGen db.IDGen
Logger *zap.Logger
}
type Service struct {
devices *repository
idGen db.IDGen
logger *zap.Logger
}
func (s *Service) Insert(user models.User, device *models.Device) error {
device.ID = s.idGen()
device.AuthToken = s.idGen()
device.UserID = user.ID
return s.devices.Insert(device)
}
func (s *Service) Select(filter ...SelectFilter) ([]models.Device, error) {
return s.devices.Select(filter...)
}
func (s *Service) Get(filter ...SelectFilter) (models.Device, error) {
return s.devices.Get(filter...)
}
func (s *Service) UpdateToken(deviceId string, token string) error {
return s.devices.UpdateToken(deviceId, token)
}
func (s *Service) UpdateLastSeen(deviceId string) error {
return s.devices.UpdateLastSeen(deviceId)
}
func NewService(params ServiceParams) *Service {
return &Service{
devices: params.Devices,
idGen: params.IDGen,
logger: params.Logger.Named("service"),
}
}

View File

@ -10,10 +10,10 @@ import (
"github.com/android-sms-gateway/client-go/smsgateway"
"github.com/capcom6/go-helpers/slices"
"github.com/capcom6/sms-gateway/internal/sms-gateway/models"
"github.com/capcom6/sms-gateway/internal/sms-gateway/modules/db"
"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/types"
"github.com/jaevor/go-nanoid"
"github.com/nyaruka/phonenumbers"
"go.uber.org/fx"
"go.uber.org/zap"
@ -37,6 +37,8 @@ type EnqueueOptions struct {
type ServiceParams struct {
fx.In
IDGen db.IDGen
Messages *repositories.MessagesRepository
HashingTask *HashingTask
@ -55,8 +57,6 @@ type Service struct {
}
func NewService(params ServiceParams) *Service {
idgen, _ := nanoid.Standard(21)
return &Service{
Messages: params.Messages,
HashingTask: params.HashingTask,
@ -64,7 +64,7 @@ func NewService(params ServiceParams) *Service {
PushSvc: params.PushSvc,
Logger: params.Logger.Named("Service"),
idgen: idgen,
idgen: params.IDGen,
}
}
@ -214,10 +214,7 @@ func (s *Service) Enqeue(device models.Device, message smsgateway.Message, opts
}
go func(token string) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.PushSvc.Enqueue(ctx, token, map[string]string{}); err != nil {
if err := s.PushSvc.Enqueue(token, push.NewMessageEnqueuedEvent()); err != nil {
s.Logger.Error("Can't enqueue message", zap.String("token", token), zap.Error(err))
}
}(*device.PushToken)

View File

@ -0,0 +1,36 @@
package push
import (
"encoding/json"
"github.com/android-sms-gateway/client-go/smsgateway"
)
type Event struct {
Event smsgateway.PushEventType
Data any
}
func (e *Event) Map() map[string]string {
json, _ := json.Marshal(e.Data)
return map[string]string{
"event": string(e.Event),
"data": string(json),
}
}
func NewEvent(event smsgateway.PushEventType, data any) *Event {
return &Event{
Event: event,
Data: data,
}
}
func NewMessageEnqueuedEvent() *Event {
return NewEvent(smsgateway.PushMessageEnqueued, nil)
}
func NewWebhooksUpdatedEvent() *Event {
return NewEvent(smsgateway.PushWebhooksUpdated, nil)
}

View File

@ -70,8 +70,8 @@ func (s *Service) Run(ctx context.Context) {
}
// 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)
func (s *Service) Enqueue(token string, event *Event) error {
s.cache.Set(token, event.Map())
return nil
}

View File

@ -0,0 +1,11 @@
package webhooks
import "github.com/android-sms-gateway/client-go/smsgateway"
func webhookToDTO(model *Webhook) smsgateway.Webhook {
return smsgateway.Webhook{
ID: model.ExtID,
URL: model.URL,
Event: model.Event,
}
}

View File

@ -0,0 +1,24 @@
package webhooks
import (
"github.com/android-sms-gateway/client-go/smsgateway"
"github.com/capcom6/sms-gateway/internal/sms-gateway/models"
"gorm.io/gorm"
)
type Webhook struct {
ID uint64 `json:"-" gorm:"->;primaryKey;type:BIGINT UNSIGNED;autoIncrement"`
ExtID string `json:"id" gorm:"not null;type:varchar(36);uniqueIndex:unq_webhooks_user_extid,priority:2"`
UserID string `json:"-" gorm:"<-:create;not null;type:varchar(32);uniqueIndex:unq_webhooks_user_extid,priority:1"`
URL string `json:"url" validate:"required,http_url" gorm:"not null;type:varchar(256)"`
Event smsgateway.WebhookEvent `json:"event" gorm:"not null;type:varchar(32)"`
User models.User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"`
models.TimedModel
}
func Migrate(db *gorm.DB) error {
return db.AutoMigrate(&Webhook{})
}

View File

@ -0,0 +1,22 @@
package webhooks
import (
"github.com/capcom6/go-infra-fx/db"
"go.uber.org/fx"
"go.uber.org/zap"
)
var Module = fx.Module(
"webhooks",
fx.Decorate(func(log *zap.Logger) *zap.Logger {
return log.Named("webhooks")
}),
fx.Provide(NewRepository, fx.Private),
fx.Provide(
NewService,
),
)
func init() {
db.RegisterMigration(Migrate)
}

View File

@ -0,0 +1,35 @@
package webhooks
import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Repository struct {
db *gorm.DB
}
func (r *Repository) Select(filters ...SelectFilter) ([]*Webhook, error) {
webhooks := []*Webhook{}
if err := newFilter(filters...).apply(r.db).Find(&webhooks).Error; err != nil {
return nil, err
}
return webhooks, nil
}
func (r *Repository) Replace(webhook *Webhook) error {
return r.db.
Clauses(clause.OnConflict{UpdateAll: true}).
Save(webhook).
Error
}
func (r *Repository) Delete(filters ...SelectFilter) error {
return newFilter(filters...).apply(r.db).Delete(&Webhook{}).Error
}
func NewRepository(db *gorm.DB) *Repository {
return &Repository{
db: db,
}
}

View File

@ -0,0 +1,42 @@
package webhooks
import "gorm.io/gorm"
type SelectFilter func(*selectFilter)
func WithExtID(extID string) SelectFilter {
return func(f *selectFilter) {
f.extID = &extID
}
}
func WithUserID(userID string) SelectFilter {
return func(f *selectFilter) {
f.userID = userID
}
}
type selectFilter struct {
userID string
extID *string
}
func newFilter(filters ...SelectFilter) *selectFilter {
f := &selectFilter{}
f.merge(filters...)
return f
}
func (f *selectFilter) merge(filters ...SelectFilter) {
for _, filter := range filters {
filter(f)
}
}
func (f *selectFilter) apply(query *gorm.DB) *gorm.DB {
query = query.Where("user_id = ?", f.userID)
if f.extID != nil {
query = query.Where("ext_id = ?", *f.extID)
}
return query
}

View File

@ -0,0 +1,112 @@
package webhooks
import (
"fmt"
"github.com/android-sms-gateway/client-go/smsgateway"
"github.com/capcom6/go-helpers/slices"
"github.com/capcom6/sms-gateway/internal/sms-gateway/modules/db"
"github.com/capcom6/sms-gateway/internal/sms-gateway/modules/devices"
"github.com/capcom6/sms-gateway/internal/sms-gateway/modules/push"
"go.uber.org/fx"
"go.uber.org/zap"
)
type ServiceParams struct {
fx.In
IDGen db.IDGen
Webhooks *Repository
DevicesSvc *devices.Service
PushSvc *push.Service
Logger *zap.Logger
}
type Service struct {
idgen db.IDGen
webhooks *Repository
devicesSvc *devices.Service
pushSvc *push.Service
logger *zap.Logger
}
func NewService(params ServiceParams) *Service {
return &Service{
idgen: params.IDGen,
webhooks: params.Webhooks,
devicesSvc: params.DevicesSvc,
pushSvc: params.PushSvc,
logger: params.Logger,
}
}
func (s *Service) Select(userID string, filters ...SelectFilter) ([]smsgateway.Webhook, error) {
filters = append(filters, WithUserID(userID))
items, err := s.webhooks.Select(filters...)
if err != nil {
return nil, fmt.Errorf("can't select webhooks: %w", err)
}
return slices.Map(items, webhookToDTO), nil
}
func (s *Service) Replace(userID string, webhook *smsgateway.Webhook) error {
if webhook.ID == "" {
webhook.ID = s.idgen()
}
model := Webhook{
ExtID: webhook.ID,
UserID: userID,
URL: webhook.URL,
Event: webhook.Event,
}
if err := s.webhooks.Replace(&model); err != nil {
return fmt.Errorf("can't replace webhook: %w", err)
}
go s.notifyDevices(userID)
return nil
}
func (s *Service) Delete(userID string, filters ...SelectFilter) error {
filters = append(filters, WithUserID(userID))
if err := s.webhooks.Delete(filters...); err != nil {
return fmt.Errorf("can't delete webhooks: %w", err)
}
go s.notifyDevices(userID)
return nil
}
func (s *Service) notifyDevices(userID string) {
s.logger.Info("Notifying devices", zap.String("user_id", userID))
devices, err := s.devicesSvc.Select(devices.WithUserID(userID))
if err != nil {
s.logger.Error("Failed to select devices", zap.String("user_id", userID), zap.Error(err))
return
}
for _, device := range devices {
if device.PushToken == nil {
continue
}
if err := s.pushSvc.Enqueue(*device.PushToken, push.NewWebhooksUpdatedEvent()); err != nil {
s.logger.Error("Failed to send push notification", zap.String("user_id", userID), zap.Error(err))
}
}
s.logger.Info("Notified devices", zap.String("user_id", userID), zap.Int("count", len(devices)))
}

View File

@ -1,56 +0,0 @@
package repositories
import (
"time"
"github.com/capcom6/sms-gateway/internal/sms-gateway/models"
"gorm.io/gorm"
)
var (
ErrDeviceNotFound = gorm.ErrRecordNotFound
)
type SelectDevicesFilter struct {
UserId *string
}
type DevicesRepository struct {
db *gorm.DB
}
func (r *DevicesRepository) Select(filter SelectDevicesFilter) ([]models.Device, error) {
devices := []models.Device{}
return devices, r.db.Where(filter).Find(&devices).Error
}
func (r *DevicesRepository) Get(id string) (models.Device, error) {
device := models.Device{}
return device, r.db.Where("id = ?", id).Take(&device).Error
}
func (r *DevicesRepository) GetByToken(token string) (models.Device, error) {
device := models.Device{}
return device, r.db.Where("auth_token = ?", token).Take(&device).Error
}
func (r *DevicesRepository) Insert(device *models.Device) error {
return r.db.Create(device).Error
}
func (r *DevicesRepository) UpdateToken(id, token string) error {
return r.db.Model(&models.Device{}).Where("id", id).Update("push_token", token).Error
}
func (r *DevicesRepository) UpdateLastSeen(id string) error {
return r.db.Model(&models.Device{}).Where("id", id).Update("last_seen", time.Now()).Error
}
func NewDevicesRepository(db *gorm.DB) *DevicesRepository {
return &DevicesRepository{
db: db,
}
}

View File

@ -11,7 +11,6 @@ var Module = fx.Module(
return log.Named("repositories")
}),
fx.Provide(
NewDevicesRepository,
NewMessagesRepository,
NewUsersRepository,
),

View File

@ -1,32 +0,0 @@
package services
import (
"github.com/capcom6/sms-gateway/internal/sms-gateway/models"
"github.com/capcom6/sms-gateway/internal/sms-gateway/repositories"
"go.uber.org/fx"
"go.uber.org/zap"
)
type DevicesServiceParams struct {
fx.In
Devices *repositories.DevicesRepository
Logger *zap.Logger
}
type DevicesService struct {
Devices *repositories.DevicesRepository
Logger *zap.Logger
}
func (s *DevicesService) Select(user models.User) ([]models.Device, error) {
return s.Devices.Select(repositories.SelectDevicesFilter{UserId: &user.ID})
}
func NewDevicesService(params DevicesServiceParams) *DevicesService {
return &DevicesService{
Devices: params.Devices,
Logger: params.Logger.Named("DevicesService"),
}
}

View File

@ -10,3 +10,19 @@ func OrDefault[T any](v *T, def T) T {
}
return *v
}
// ZeroDefault returns the default value if the given value is zero, otherwise it returns the value.
//
// Parameters:
// - v: The value to check.
// - def: The default value to return if v is zero.
//
// Returns:
// - The default value if v is zero, otherwise the value.
func ZeroDefault[T comparable](v T, def T) T {
zero := new(T)
if v == *zero {
return def
}
return v
}

36
pkg/types/types_test.go Normal file
View File

@ -0,0 +1,36 @@
package types
import (
"testing"
)
func TestZeroDefault(t *testing.T) {
tests := []struct {
name string
value string
def string
want string
}{
{
name: "String zero value",
value: "",
def: "default",
want: "default",
},
{
name: "String non-zero value",
value: "value",
def: "default",
want: "value",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ZeroDefault(tt.value, tt.def)
if got != tt.want {
t.Errorf("ZeroDefault() = %v, want %v", got, tt.want)
}
})
}
}