From ad4f82b6439068c1280c5821947223cca7cf5a85 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Fri, 18 Apr 2025 13:31:39 +0200 Subject: [PATCH 001/336] Add repository methods for dealing with API keys --- api/pkg/di/config.go | 10 + api/pkg/di/container.go | 28 +-- api/pkg/entities/auth_context.go | 16 ++ api/pkg/entities/auth_user.go | 12 -- api/pkg/entities/phone_api_key.go | 26 +++ api/pkg/handlers/handler.go | 4 +- .../middlewares/api_key_auth_middleware.go | 2 +- .../middlewares/authenticated_middlesare.go | 2 +- .../bearer_api_key_auth_middleware.go | 2 +- api/pkg/middlewares/bearer_auth_middleware.go | 2 +- .../gorm_phone_api_key_repository.go | 176 ++++++++++++++++++ api/pkg/repositories/gorm_user_repository.go | 14 +- .../repositories/phone_api_key_repository.go | 30 +++ api/pkg/repositories/user_repository.go | 8 +- api/pkg/requests/discord_store_request.go | 2 +- api/pkg/requests/discord_update_request.go | 2 +- api/pkg/requests/heartbeat_store_request.go | 2 +- api/pkg/requests/phone_update_request.go | 2 +- api/pkg/requests/webhook_store_request.go | 2 +- api/pkg/requests/webhook_update_request.go | 2 +- api/pkg/services/phone_service.go | 2 +- api/pkg/services/user_service.go | 10 +- 22 files changed, 296 insertions(+), 60 deletions(-) create mode 100644 api/pkg/entities/auth_context.go delete mode 100644 api/pkg/entities/auth_user.go create mode 100644 api/pkg/entities/phone_api_key.go create mode 100644 api/pkg/repositories/gorm_phone_api_key_repository.go create mode 100644 api/pkg/repositories/phone_api_key_repository.go diff --git a/api/pkg/di/config.go b/api/pkg/di/config.go index 4655abc2..586934f9 100644 --- a/api/pkg/di/config.go +++ b/api/pkg/di/config.go @@ -2,6 +2,7 @@ package di import ( "log" + "os" "github.com/joho/godotenv" ) @@ -13,3 +14,12 @@ func LoadEnv(filenames ...string) { log.Fatalf("Fatal: cannot load .env file: %v", err) } } + +func getEnvWithDefault(key, defaultValue string) string { + value := os.Getenv(key) + if value == "" { + return defaultValue + } + + return value +} diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index 88c0adb8..84fa7ea5 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -153,15 +153,6 @@ func NewContainer(projectID string, version string) (container *Container) { return container } -func GetEnvWithDefault(key, defaultValue string) string { - value := os.Getenv(key) - if value == "" { - return defaultValue - } - - return value -} - // App creates a new instance of fiber.App func (container *Container) App() (app *fiber.App) { if container.app != nil { @@ -179,15 +170,14 @@ func (container *Container) App() (app *fiber.App) { app.Use(otelfiber.Middleware()) app.Use(cors.New( cors.Config{ - AllowOrigins: GetEnvWithDefault("CORS_ALLOW_ORIGINS", "*"), - AllowHeaders: GetEnvWithDefault("CORS_ALLOW_HEADERS", "*"), - AllowMethods: GetEnvWithDefault("CORS_ALLOW_METHODS", "GET,POST,PUT,DELETE,OPTIONS"), + AllowOrigins: getEnvWithDefault("CORS_ALLOW_ORIGINS", "*"), + AllowHeaders: getEnvWithDefault("CORS_ALLOW_HEADERS", "*"), + AllowMethods: getEnvWithDefault("CORS_ALLOW_METHODS", "GET,POST,PUT,DELETE,OPTIONS"), AllowCredentials: false, - ExposeHeaders: GetEnvWithDefault("CORS_EXPOSE_HEADERS", "*"), - })) - + ExposeHeaders: getEnvWithDefault("CORS_EXPOSE_HEADERS", "*"), + }), + ) app.Use(middlewares.HTTPRequestLogger(container.Tracer(), container.Logger())) - app.Use(middlewares.BearerAuth(container.Logger(), container.Tracer(), container.FirebaseAuthClient())) app.Use(middlewares.APIKeyAuth(container.Logger(), container.Tracer(), container.UserRepository())) @@ -1396,10 +1386,10 @@ func (container *Container) UserRepository() repositories.UserRepository { ) } -// UserRistrettoCache creates an in-memory *ristretto.Cache[string, entities.AuthUser] -func (container *Container) UserRistrettoCache() (cache *ristretto.Cache[string, entities.AuthUser]) { +// UserRistrettoCache creates an in-memory *ristretto.Cache[string, entities.AuthContext] +func (container *Container) UserRistrettoCache() (cache *ristretto.Cache[string, entities.AuthContext]) { container.logger.Debug(fmt.Sprintf("creating %T", cache)) - ristrettoCache, err := ristretto.NewCache[string, entities.AuthUser](&ristretto.Config[string, entities.AuthUser]{ + ristrettoCache, err := ristretto.NewCache[string, entities.AuthContext](&ristretto.Config[string, entities.AuthContext]{ MaxCost: 5000, NumCounters: 5000 * 10, BufferItems: 64, diff --git a/api/pkg/entities/auth_context.go b/api/pkg/entities/auth_context.go new file mode 100644 index 00000000..fd6db242 --- /dev/null +++ b/api/pkg/entities/auth_context.go @@ -0,0 +1,16 @@ +package entities + +import "github.com/google/uuid" + +// AuthContext is the user gotten from an auth request +type AuthContext struct { + ID UserID `json:"id"` + PhoneAPIKeyID *uuid.UUID `json:"phone_api_key_id"` + PhoneNumbers []string `json:"phone_numbers"` + Email string `json:"email"` +} + +// IsNoop checks if a user is empty +func (user AuthContext) IsNoop() bool { + return user.ID == "" || user.Email == "" +} diff --git a/api/pkg/entities/auth_user.go b/api/pkg/entities/auth_user.go deleted file mode 100644 index 494895bc..00000000 --- a/api/pkg/entities/auth_user.go +++ /dev/null @@ -1,12 +0,0 @@ -package entities - -// AuthUser is the user gotten from an auth request -type AuthUser struct { - ID UserID `json:"id"` - Email string `json:"email"` -} - -// IsNoop checks if a user is empty -func (user AuthUser) IsNoop() bool { - return user.ID == "" || user.Email == "" -} diff --git a/api/pkg/entities/phone_api_key.go b/api/pkg/entities/phone_api_key.go new file mode 100644 index 00000000..89a2a6c9 --- /dev/null +++ b/api/pkg/entities/phone_api_key.go @@ -0,0 +1,26 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" + "github.com/lib/pq" +) + +// PhoneAPIKey represents the API key for a phone +type PhoneAPIKey struct { + ID uuid.UUID `json:"id" gorm:"primaryKey;type:uuid;" example:"32343a19-da5e-4b1b-a767-3298a73703cb"` + Name string `json:"name" example:"Business Phone Key"` + UserID UserID `json:"user_id" example:"WB7DRDWrJZRGbYrv2CKGkqbzvqdC"` + UserEmail string `json:"user_email" example:"user@gmail.com"` + PhoneNumbers pq.StringArray `json:"phone_numbers" example:"[+18005550199,+18005550100]" gorm:"type:text[]" swaggertype:"array,string"` + PhoneIDs pq.StringArray `json:"phone_ids" example:"[32343a19-da5e-4b1b-a767-3298a73703cb,32343a19-da5e-4b1b-a767-3298a73703cc]" gorm:"type:text[]" swaggertype:"array,string"` + APIKey string `json:"api_key" example:"pk_DGW8NwQp7mxKaSZ72Xq9v67SLqSbWQvckzzmK8D6rvd7NywSEkdMJtuxKyEkYnCY"` + CreatedAt time.Time `json:"created_at" example:"2022-06-05T14:26:02.302718+03:00"` + UpdatedAt time.Time `json:"updated_at" example:"2022-06-05T14:26:02.302718+03:00"` +} + +// TableName overrides the table name used by PhoneAPIKey +func (PhoneAPIKey) TableName() string { + return "phone_api_keys" +} diff --git a/api/pkg/handlers/handler.go b/api/pkg/handlers/handler.go index c5e15388..bfd54dee 100644 --- a/api/pkg/handlers/handler.go +++ b/api/pkg/handlers/handler.go @@ -101,8 +101,8 @@ func (h *handler) pluralize(value string, count int) string { return value + "s" } -func (h *handler) userFromContext(c *fiber.Ctx) entities.AuthUser { - if tokenUser, ok := c.Locals(middlewares.ContextKeyAuthUserID).(entities.AuthUser); ok && !tokenUser.IsNoop() { +func (h *handler) userFromContext(c *fiber.Ctx) entities.AuthContext { + if tokenUser, ok := c.Locals(middlewares.ContextKeyAuthUserID).(entities.AuthContext); ok && !tokenUser.IsNoop() { return tokenUser } panic("user does not exist in context.") diff --git a/api/pkg/middlewares/api_key_auth_middleware.go b/api/pkg/middlewares/api_key_auth_middleware.go index 1c29eefd..cc797bff 100644 --- a/api/pkg/middlewares/api_key_auth_middleware.go +++ b/api/pkg/middlewares/api_key_auth_middleware.go @@ -25,7 +25,7 @@ func APIKeyAuth(logger telemetry.Logger, tracer telemetry.Tracer, userRepository return c.Next() } - authUser, err := userRepository.LoadAuthUser(ctx, apiKey) + authUser, err := userRepository.LoadAuthContext(ctx, apiKey) if err != nil { ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot load user with api key [%s]", apiKey))) return c.Next() diff --git a/api/pkg/middlewares/authenticated_middlesare.go b/api/pkg/middlewares/authenticated_middlesare.go index 8e9bac19..103d426c 100644 --- a/api/pkg/middlewares/authenticated_middlesare.go +++ b/api/pkg/middlewares/authenticated_middlesare.go @@ -23,7 +23,7 @@ func Authenticated(tracer telemetry.Tracer) fiber.Handler { _, span := tracer.StartFromFiberCtx(c, "middlewares.Authenticated") defer span.End() - if tokenUser, ok := c.Locals(ContextKeyAuthUserID).(entities.AuthUser); !ok || tokenUser.IsNoop() { + if tokenUser, ok := c.Locals(ContextKeyAuthUserID).(entities.AuthContext); !ok || tokenUser.IsNoop() { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ "status": "error", "message": "You are not authorized to carry out this request.", diff --git a/api/pkg/middlewares/bearer_api_key_auth_middleware.go b/api/pkg/middlewares/bearer_api_key_auth_middleware.go index e12face7..2b1dc1c2 100644 --- a/api/pkg/middlewares/bearer_api_key_auth_middleware.go +++ b/api/pkg/middlewares/bearer_api_key_auth_middleware.go @@ -26,7 +26,7 @@ func BearerAPIKeyAuth(logger telemetry.Logger, tracer telemetry.Tracer, userRepo return c.Next() } - authUser, err := userRepository.LoadAuthUser(ctx, apiKey) + authUser, err := userRepository.LoadAuthContext(ctx, apiKey) if err != nil { ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot load user with api key [%s] using header [%s]", apiKey, c.Get(authHeaderBearer)))) return c.Next() diff --git a/api/pkg/middlewares/bearer_auth_middleware.go b/api/pkg/middlewares/bearer_auth_middleware.go index bd6563c1..7df1ca3a 100644 --- a/api/pkg/middlewares/bearer_auth_middleware.go +++ b/api/pkg/middlewares/bearer_auth_middleware.go @@ -40,7 +40,7 @@ func BearerAuth(logger telemetry.Logger, tracer telemetry.Tracer, authClient *au span.AddEvent(fmt.Sprintf("[%s] token is valid", bearerScheme)) - authUser := entities.AuthUser{ + authUser := entities.AuthContext{ Email: token.Claims["email"].(string), ID: entities.UserID(token.Claims["user_id"].(string)), } diff --git a/api/pkg/repositories/gorm_phone_api_key_repository.go b/api/pkg/repositories/gorm_phone_api_key_repository.go new file mode 100644 index 00000000..7e88355e --- /dev/null +++ b/api/pkg/repositories/gorm_phone_api_key_repository.go @@ -0,0 +1,176 @@ +package repositories + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/dgraph-io/ristretto" + "github.com/google/uuid" + "github.com/palantir/stacktrace" + "gorm.io/gorm" +) + +// gormPhoneAPIKeyRepository is responsible for persisting entities.PhoneAPIKey +type gormPhoneAPIKeyRepository struct { + logger telemetry.Logger + tracer telemetry.Tracer + cache *ristretto.Cache[string, entities.AuthContext] + db *gorm.DB +} + +// NewGormPhoneAPIKeyRepository creates the GORM version of the PhoneAPIKeyRepository +func NewGormPhoneAPIKeyRepository( + logger telemetry.Logger, + tracer telemetry.Tracer, + cache *ristretto.Cache[string, entities.AuthContext], + db *gorm.DB, +) PhoneAPIKeyRepository { + return &gormPhoneAPIKeyRepository{ + logger: logger.WithService(fmt.Sprintf("%T", &gormPhoneAPIKeyRepository{})), + tracer: tracer, + cache: cache, + db: db, + } +} + +func (repository *gormPhoneAPIKeyRepository) Create(ctx context.Context, phoneAPIKey *entities.PhoneAPIKey) error { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + if err := repository.db.WithContext(ctx).Create(phoneAPIKey).Error; err != nil { + msg := fmt.Sprintf("cannot save phone API key with ID [%s] for user with ID [%s]", phoneAPIKey.ID, phoneAPIKey.UserID) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + return nil +} + +func (repository *gormPhoneAPIKeyRepository) LoadAuthContext(ctx context.Context, apiKey string) (entities.AuthContext, error) { + ctx, span, ctxLogger := repository.tracer.StartWithLogger(ctx, repository.logger) + defer span.End() + + if authContext, found := repository.cache.Get(apiKey); found { + ctxLogger.Info(fmt.Sprintf("cache hit for user with ID [%s] and phone API Key ID [%s]", authContext.ID, *authContext.PhoneAPIKeyID)) + return authContext, nil + } + + phoneAPIKey := new(entities.PhoneAPIKey) + err := repository.db.WithContext(ctx).Where("api_key = ?", phoneAPIKey).First(apiKey).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + msg := fmt.Sprintf("phone api key [%s] does not exist", apiKey) + return entities.AuthContext{}, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, msg)) + } + + if err != nil { + msg := fmt.Sprintf("cannot load phone api key [%s]", apiKey) + return entities.AuthContext{}, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + authUser := entities.AuthContext{ + ID: phoneAPIKey.UserID, + Email: phoneAPIKey.UserEmail, + PhoneAPIKeyID: &phoneAPIKey.ID, + PhoneNumbers: phoneAPIKey.PhoneNumbers, + } + + if result := repository.cache.SetWithTTL(apiKey, authUser, 1, 2*time.Hour); !result { + msg := fmt.Sprintf("cannot cache [%T] with ID [%s] and result [%t]", authUser, phoneAPIKey.ID, result) + ctxLogger.Error(repository.tracer.WrapErrorSpan(span, stacktrace.NewError(msg))) + } + + return authUser, nil +} + +func (repository *gormPhoneAPIKeyRepository) Index(ctx context.Context, userID entities.UserID, params IndexParams) ([]*entities.PhoneAPIKey, error) { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + query := repository.db.WithContext(ctx).Where("user_id = ?", userID) + if len(params.Query) > 0 { + queryPattern := "%" + params.Query + "%" + query.Where("name ILIKE ?", queryPattern) + } + + phoneAPIKeys := new([]*entities.PhoneAPIKey) + if err := query.Order("created_at DESC").Limit(params.Limit).Offset(params.Skip).Find(phoneAPIKeys).Error; err != nil { + msg := fmt.Sprintf("cannot fetch phone API Keys with userID [%s] and params [%+#v]", userID, params) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + return *phoneAPIKeys, nil +} + +func (repository *gormPhoneAPIKeyRepository) Delete(ctx context.Context, userID entities.UserID, phoneAPIKeyID uuid.UUID) error { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + err := repository.db.WithContext(ctx). + Where("user_id = ?", userID). + Where("id = ?", phoneAPIKeyID). + Delete(&entities.PhoneAPIKey{}).Error + if err != nil { + msg := fmt.Sprintf("cannot delete phone API key with ID [%s] and userID [%s]", phoneAPIKeyID, userID) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + return nil +} + +func (repository *gormPhoneAPIKeyRepository) AddPhone(ctx context.Context, authContext entities.AuthContext, phone *entities.Phone) error { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + query := ` +UPDATE ? +SET phone_ids = array_append(phone_ids, ?), + phone_numbers = array_append(phone_numbers, ?) +WHERE array_position(phone_ids, ?) IS NULL AND id = ?; +` + + err := repository.db.WithContext(ctx). + Raw(query, (entities.PhoneAPIKey{}).TableName(), phone.ID, phone.PhoneNumber, phone.ID, *authContext.PhoneAPIKeyID). + Error + if err != nil { + msg := fmt.Sprintf("cannot add phone with ID [%s] to phone API key with ID [%s]", phone.ID, *authContext.PhoneAPIKeyID) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + return nil +} + +func (repository *gormPhoneAPIKeyRepository) RemovePhone(ctx context.Context, authContext entities.AuthContext, phone *entities.Phone) error { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + query := ` +UPDATE ? +SET phone_ids = array_remove(phone_ids, ?), + phone_numbers = array_remove(phone_numbers, ?) +WHERE id = ?; +` + err := repository.db.WithContext(ctx). + Raw(query, (entities.PhoneAPIKey{}).TableName(), phone.ID, phone.PhoneNumber, *authContext.PhoneAPIKeyID). + Error + if err != nil { + msg := fmt.Sprintf("cannot remove phone with ID [%s] to phone API key with ID [%s]", phone.ID, *authContext.PhoneAPIKeyID) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + return nil +} + +func (repository *gormPhoneAPIKeyRepository) DeleteAllForUser(ctx context.Context, userID entities.UserID) error { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + if err := repository.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&entities.PhoneAPIKey{}).Error; err != nil { + msg := fmt.Sprintf("cannot delete all [%T] for user with ID [%s]", &entities.PhoneAPIKey{}, userID) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + return nil +} diff --git a/api/pkg/repositories/gorm_user_repository.go b/api/pkg/repositories/gorm_user_repository.go index dcf9bd8e..915cb4cc 100644 --- a/api/pkg/repositories/gorm_user_repository.go +++ b/api/pkg/repositories/gorm_user_repository.go @@ -23,7 +23,7 @@ import ( type gormUserRepository struct { logger telemetry.Logger tracer telemetry.Tracer - cache *ristretto.Cache[string, entities.AuthUser] + cache *ristretto.Cache[string, entities.AuthContext] db *gorm.DB } @@ -31,7 +31,7 @@ type gormUserRepository struct { func NewGormUserRepository( logger telemetry.Logger, tracer telemetry.Tracer, - cache *ristretto.Cache[string, entities.AuthUser], + cache *ristretto.Cache[string, entities.AuthContext], db *gorm.DB, ) UserRepository { return &gormUserRepository{ @@ -149,7 +149,7 @@ func (repository *gormUserRepository) Update(ctx context.Context, user *entities return nil } -func (repository *gormUserRepository) LoadAuthUser(ctx context.Context, apiKey string) (entities.AuthUser, error) { +func (repository *gormUserRepository) LoadAuthContext(ctx context.Context, apiKey string) (entities.AuthContext, error) { ctx, span, ctxLogger := repository.tracer.StartWithLogger(ctx, repository.logger) defer span.End() @@ -162,15 +162,15 @@ func (repository *gormUserRepository) LoadAuthUser(ctx context.Context, apiKey s err := repository.db.WithContext(ctx).Where("api_key = ?", apiKey).First(user).Error if errors.Is(err, gorm.ErrRecordNotFound) { msg := fmt.Sprintf("user with api key [%s] does not exist", apiKey) - return entities.AuthUser{}, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, msg)) + return entities.AuthContext{}, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, msg)) } if err != nil { msg := fmt.Sprintf("cannot load user with api key [%s]", apiKey) - return entities.AuthUser{}, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + return entities.AuthContext{}, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } - authUser := entities.AuthUser{ + authUser := entities.AuthContext{ ID: user.ID, Email: user.Email, } @@ -202,7 +202,7 @@ func (repository *gormUserRepository) Load(ctx context.Context, userID entities. return user, nil } -func (repository *gormUserRepository) LoadOrStore(ctx context.Context, authUser entities.AuthUser) (*entities.User, bool, error) { +func (repository *gormUserRepository) LoadOrStore(ctx context.Context, authUser entities.AuthContext) (*entities.User, bool, error) { ctx, span := repository.tracer.Start(ctx) defer span.End() diff --git a/api/pkg/repositories/phone_api_key_repository.go b/api/pkg/repositories/phone_api_key_repository.go new file mode 100644 index 00000000..c443d521 --- /dev/null +++ b/api/pkg/repositories/phone_api_key_repository.go @@ -0,0 +1,30 @@ +package repositories + +import ( + "context" + + "github.com/google/uuid" + + "github.com/NdoleStudio/httpsms/pkg/entities" +) + +// PhoneAPIKeyRepository loads and persists an entities.PhoneAPIKey +type PhoneAPIKeyRepository interface { + // Create a new entities.PhoneAPIKey + Create(ctx context.Context, phone *entities.PhoneAPIKey) error + + // LoadAuthContext fetches an entities.AuthContext by apiKey + LoadAuthContext(ctx context.Context, apiKey string) (entities.AuthContext, error) + + // Index entities.PhoneAPIKey of a user + Index(ctx context.Context, userID entities.UserID, params IndexParams) ([]*entities.PhoneAPIKey, error) + + // Delete an entities.PhoneAPIKey + Delete(ctx context.Context, userID entities.UserID, phoneAPIKeyID uuid.UUID) error + + // AddPhone an entities.Phone to an entities.PhoneAPIKey + AddPhone(ctx context.Context, authContext entities.AuthContext, phone *entities.Phone) error + + // DeleteAllForUser deletes all entities.PhoneAPIKey for a user + DeleteAllForUser(ctx context.Context, userID entities.UserID) error +} diff --git a/api/pkg/repositories/user_repository.go b/api/pkg/repositories/user_repository.go index 1c3ae38a..e0c59a36 100644 --- a/api/pkg/repositories/user_repository.go +++ b/api/pkg/repositories/user_repository.go @@ -14,8 +14,8 @@ type UserRepository interface { // Update a new entities.User Update(ctx context.Context, user *entities.User) error - // LoadAuthUser fetches an entities.AuthUser by apiKey - LoadAuthUser(ctx context.Context, apiKey string) (entities.AuthUser, error) + // LoadAuthContext fetches an entities.AuthContext by apiKey + LoadAuthContext(ctx context.Context, apiKey string) (entities.AuthContext, error) // Load an entities.User by entities.UserID Load(ctx context.Context, userID entities.UserID) (*entities.User, error) @@ -23,8 +23,8 @@ type UserRepository interface { // RotateAPIKey updates the API Key of a user RotateAPIKey(ctx context.Context, userID entities.UserID) (*entities.User, error) - // LoadOrStore an entities.User by entities.AuthUser - LoadOrStore(ctx context.Context, user entities.AuthUser) (*entities.User, bool, error) + // LoadOrStore an entities.User by entities.AuthContext + LoadOrStore(ctx context.Context, user entities.AuthContext) (*entities.User, bool, error) // LoadBySubscriptionID loads a user based on the lemonsqueezy subscriptionID LoadBySubscriptionID(ctx context.Context, subscriptionID string) (*entities.User, error) diff --git a/api/pkg/requests/discord_store_request.go b/api/pkg/requests/discord_store_request.go index f73458e1..dd4c91a0 100644 --- a/api/pkg/requests/discord_store_request.go +++ b/api/pkg/requests/discord_store_request.go @@ -24,7 +24,7 @@ func (input *DiscordStore) Sanitize() DiscordStore { } // ToStoreParams converts DiscordStore to services.WebhookStoreParams -func (input *DiscordStore) ToStoreParams(user entities.AuthUser) *services.DiscordStoreParams { +func (input *DiscordStore) ToStoreParams(user entities.AuthContext) *services.DiscordStoreParams { return &services.DiscordStoreParams{ UserID: user.ID, Name: input.Name, diff --git a/api/pkg/requests/discord_update_request.go b/api/pkg/requests/discord_update_request.go index 30b05d95..3b30e49d 100644 --- a/api/pkg/requests/discord_update_request.go +++ b/api/pkg/requests/discord_update_request.go @@ -19,7 +19,7 @@ func (input *DiscordUpdate) Sanitize() DiscordUpdate { } // ToUpdateParams converts DiscordUpdate to services.DiscordUpdateParams -func (input *DiscordUpdate) ToUpdateParams(user entities.AuthUser) *services.DiscordUpdateParams { +func (input *DiscordUpdate) ToUpdateParams(user entities.AuthContext) *services.DiscordUpdateParams { return &services.DiscordUpdateParams{ UserID: user.ID, Name: input.Name, diff --git a/api/pkg/requests/heartbeat_store_request.go b/api/pkg/requests/heartbeat_store_request.go index 9be48c26..996225b9 100644 --- a/api/pkg/requests/heartbeat_store_request.go +++ b/api/pkg/requests/heartbeat_store_request.go @@ -28,7 +28,7 @@ func (input *HeartbeatStore) Sanitize() HeartbeatStore { } // ToStoreParams converts HeartbeatIndex to repositories.IndexParams -func (input *HeartbeatStore) ToStoreParams(user entities.AuthUser, source string, version string) []services.HeartbeatStoreParams { +func (input *HeartbeatStore) ToStoreParams(user entities.AuthContext, source string, version string) []services.HeartbeatStoreParams { var params []services.HeartbeatStoreParams for _, phoneNumber := range input.PhoneNumbers { params = append(params, services.HeartbeatStoreParams{ diff --git a/api/pkg/requests/phone_update_request.go b/api/pkg/requests/phone_update_request.go index 5b9d0fac..f920fad4 100644 --- a/api/pkg/requests/phone_update_request.go +++ b/api/pkg/requests/phone_update_request.go @@ -42,7 +42,7 @@ func (input *PhoneUpsert) Sanitize() PhoneUpsert { } // ToUpsertParams converts PhoneUpsert to services.PhoneUpsertParams -func (input *PhoneUpsert) ToUpsertParams(user entities.AuthUser, source string) *services.PhoneUpsertParams { +func (input *PhoneUpsert) ToUpsertParams(user entities.AuthContext, source string) *services.PhoneUpsertParams { phone, _ := phonenumbers.Parse(input.PhoneNumber, phonenumbers.UNKNOWN_REGION) // ignore value if it's default diff --git a/api/pkg/requests/webhook_store_request.go b/api/pkg/requests/webhook_store_request.go index fca0a7d2..f45ee170 100644 --- a/api/pkg/requests/webhook_store_request.go +++ b/api/pkg/requests/webhook_store_request.go @@ -31,7 +31,7 @@ func (input *WebhookStore) Sanitize() WebhookStore { } // ToStoreParams converts WebhookStore to services.WebhookStoreParams -func (input *WebhookStore) ToStoreParams(user entities.AuthUser) *services.WebhookStoreParams { +func (input *WebhookStore) ToStoreParams(user entities.AuthContext) *services.WebhookStoreParams { return &services.WebhookStoreParams{ UserID: user.ID, SigningKey: input.SigningKey, diff --git a/api/pkg/requests/webhook_update_request.go b/api/pkg/requests/webhook_update_request.go index 30eb95dc..aeaf989e 100644 --- a/api/pkg/requests/webhook_update_request.go +++ b/api/pkg/requests/webhook_update_request.go @@ -19,7 +19,7 @@ func (input *WebhookUpdate) Sanitize() WebhookUpdate { } // ToUpdateParams converts WebhookUpdate to services.WebhookUpdateParams -func (input *WebhookUpdate) ToUpdateParams(user entities.AuthUser) *services.WebhookUpdateParams { +func (input *WebhookUpdate) ToUpdateParams(user entities.AuthContext) *services.WebhookUpdateParams { return &services.WebhookUpdateParams{ UserID: user.ID, WebhookID: uuid.MustParse(input.WebhookID), diff --git a/api/pkg/services/phone_service.go b/api/pkg/services/phone_service.go index 8dcaedd4..e4960ff6 100644 --- a/api/pkg/services/phone_service.go +++ b/api/pkg/services/phone_service.go @@ -57,7 +57,7 @@ func (service *PhoneService) DeleteAllForUser(ctx context.Context, userID entiti } // Index fetches the heartbeats for a phone number -func (service *PhoneService) Index(ctx context.Context, authUser entities.AuthUser, params repositories.IndexParams) (*[]entities.Phone, error) { +func (service *PhoneService) Index(ctx context.Context, authUser entities.AuthContext, params repositories.IndexParams) (*[]entities.Phone, error) { ctx, span := service.tracer.Start(ctx) defer span.End() diff --git a/api/pkg/services/user_service.go b/api/pkg/services/user_service.go index 78eca44c..731f50a9 100644 --- a/api/pkg/services/user_service.go +++ b/api/pkg/services/user_service.go @@ -60,7 +60,7 @@ func NewUserService( } // Get fetches or creates an entities.User -func (service *UserService) Get(ctx context.Context, authUser entities.AuthUser) (*entities.User, error) { +func (service *UserService) Get(ctx context.Context, authUser entities.AuthContext) (*entities.User, error) { ctx, span := service.tracer.Start(ctx) defer span.End() @@ -98,7 +98,7 @@ type UserUpdateParams struct { } // Update an entities.User -func (service *UserService) Update(ctx context.Context, authUser entities.AuthUser, params UserUpdateParams) (*entities.User, error) { +func (service *UserService) Update(ctx context.Context, authUser entities.AuthContext, params UserUpdateParams) (*entities.User, error) { ctx, span := service.tracer.Start(ctx) defer span.End() @@ -442,16 +442,16 @@ func (service *UserService) UpdateSubscription(ctx context.Context, params *even return nil } -// DeleteAuthUser deletes an entities.AuthUser from firebase +// DeleteAuthUser deletes an entities.AuthContext from firebase func (service *UserService) DeleteAuthUser(ctx context.Context, userID entities.UserID) error { ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() if err := service.authClient.DeleteUser(ctx, userID.String()); err != nil { - msg := fmt.Sprintf("could not delete [entities.AuthUser] from firebase with ID [%s]", userID) + msg := fmt.Sprintf("could not delete [entities.AuthContext] from firebase with ID [%s]", userID) return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } - ctxLogger.Info(fmt.Sprintf("deleted [entities.AuthUser] from firebase for user with ID [%s]", userID)) + ctxLogger.Info(fmt.Sprintf("deleted [entities.AuthContext] from firebase for user with ID [%s]", userID)) return nil } From 2dac7f71a4621132358eb740d40cb9b08e1e26d1 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Fri, 18 Apr 2025 16:39:20 +0200 Subject: [PATCH 002/336] Add api hanlders for the phone API key endpoint --- api/pkg/handlers/discord_handler.go | 2 +- api/pkg/handlers/handler.go | 12 ++ api/pkg/handlers/message_handler.go | 2 +- api/pkg/handlers/message_thread_handler.go | 2 +- api/pkg/handlers/phone_api_key_handler.go | 175 ++++++++++++++++++ api/pkg/handlers/webhook_handler.go | 2 +- .../gorm_phone_api_key_repository.go | 48 +++-- .../repositories/phone_api_key_repository.go | 12 +- .../requests/phone_api_key_store_request.go | 13 ++ api/pkg/services/phone_api_key_service.go | 128 +++++++++++++ .../phone_api_key_handler_validator.go | 44 +++++ api/pkg/validators/validator.go | 3 +- 12 files changed, 421 insertions(+), 22 deletions(-) create mode 100644 api/pkg/handlers/phone_api_key_handler.go create mode 100644 api/pkg/requests/phone_api_key_store_request.go create mode 100644 api/pkg/services/phone_api_key_service.go create mode 100644 api/pkg/validators/phone_api_key_handler_validator.go diff --git a/api/pkg/handlers/discord_handler.go b/api/pkg/handlers/discord_handler.go index 28df3ef1..95591c2b 100644 --- a/api/pkg/handlers/discord_handler.go +++ b/api/pkg/handlers/discord_handler.go @@ -128,7 +128,7 @@ func (h *DiscordHandler) Delete(c *fiber.Ctx) error { defer span.End() discordID := c.Params("discordID") - if errors := h.validator.ValidateUUID(ctx, discordID, "discordID"); len(errors) != 0 { + if errors := h.validator.ValidateUUID(discordID, "discordID"); len(errors) != 0 { msg := fmt.Sprintf("validation errors [%s], while deleting discord integration with ID [%s]", spew.Sdump(errors), discordID) ctxLogger.Warn(stacktrace.NewError(msg)) return h.responseUnprocessableEntity(c, errors, "validation errors while deleting discord integration") diff --git a/api/pkg/handlers/handler.go b/api/pkg/handlers/handler.go index bfd54dee..55dfb353 100644 --- a/api/pkg/handlers/handler.go +++ b/api/pkg/handlers/handler.go @@ -115,3 +115,15 @@ func (h *handler) userIDFomContext(c *fiber.Ctx) entities.UserID { func (h *handler) computeRoute(middlewares []fiber.Handler, route fiber.Handler) []fiber.Handler { return append(append([]fiber.Handler{}, middlewares...), route) } + +func (h *handler) mergeErrors(errors ...url.Values) url.Values { + result := url.Values{} + for _, item := range errors { + for key, values := range item { + for _, value := range values { + result.Add(key, value) + } + } + } + return result +} diff --git a/api/pkg/handlers/message_handler.go b/api/pkg/handlers/message_handler.go index c1046c73..55e574c3 100644 --- a/api/pkg/handlers/message_handler.go +++ b/api/pkg/handlers/message_handler.go @@ -394,7 +394,7 @@ func (h *MessageHandler) Delete(c *fiber.Ctx) error { ctxLogger := h.tracer.CtxLogger(h.logger, span) messageID := c.Params("messageID") - if errors := h.validator.ValidateUUID(ctx, messageID, "messageID"); len(errors) != 0 { + if errors := h.validator.ValidateUUID(messageID, "messageID"); len(errors) != 0 { msg := fmt.Sprintf("validation errors [%s], while deleting a message with ID [%s]", spew.Sdump(errors), messageID) ctxLogger.Warn(stacktrace.NewError(msg)) return h.responseUnprocessableEntity(c, errors, "validation errors while storing event") diff --git a/api/pkg/handlers/message_thread_handler.go b/api/pkg/handlers/message_thread_handler.go index 823fb587..38282831 100644 --- a/api/pkg/handlers/message_thread_handler.go +++ b/api/pkg/handlers/message_thread_handler.go @@ -157,7 +157,7 @@ func (h *MessageThreadHandler) Delete(c *fiber.Ctx) error { defer span.End() messageThreadID := c.Params("messageThreadID") - if errors := h.validator.ValidateUUID(ctx, messageThreadID, "messageThreadID"); len(errors) != 0 { + if errors := h.validator.ValidateUUID(messageThreadID, "messageThreadID"); len(errors) != 0 { msg := fmt.Sprintf("validation errors [%s], while deleting a thread thread with ID [%s]", spew.Sdump(errors), messageThreadID) ctxLogger.Warn(stacktrace.NewError(msg)) return h.responseUnprocessableEntity(c, errors, "validation errors while deleting a thread thread") diff --git a/api/pkg/handlers/phone_api_key_handler.go b/api/pkg/handlers/phone_api_key_handler.go new file mode 100644 index 00000000..227aa773 --- /dev/null +++ b/api/pkg/handlers/phone_api_key_handler.go @@ -0,0 +1,175 @@ +package handlers + +import ( + "fmt" + + "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/httpsms/pkg/requests" + "github.com/NdoleStudio/httpsms/pkg/services" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/NdoleStudio/httpsms/pkg/validators" + "github.com/davecgh/go-spew/spew" + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/palantir/stacktrace" +) + +// PhoneAPIKeyHandler handles phone API key http requests +type PhoneAPIKeyHandler struct { + handler + logger telemetry.Logger + tracer telemetry.Tracer + validator *validators.PhoneAPIKeyHandlerValidator + service *services.PhoneAPIKeyService + phoneService *services.PhoneService +} + +// NewPhoneAPIKeyHandler creates a new PhoneAPIKeyHandler +func NewPhoneAPIKeyHandler( + logger telemetry.Logger, + tracer telemetry.Tracer, + validator *validators.PhoneAPIKeyHandlerValidator, + service *services.PhoneAPIKeyService, + phoneService *services.PhoneService, +) *PhoneAPIKeyHandler { + return &PhoneAPIKeyHandler{ + logger: logger.WithService(fmt.Sprintf("%T", &PhoneAPIKeyHandler{})), + tracer: tracer, + validator: validator, + service: service, + phoneService: phoneService, + } +} + +// RegisterRoutes registers the routes for the PhoneAPIKeyHandler +func (h *PhoneAPIKeyHandler) RegisterRoutes(app *fiber.App, middlewares ...fiber.Handler) { + router := app.Group("/v1/api-keys/") + router.Post("/", h.computeRoute(middlewares, h.Store)...) + router.Delete("/:phoneAPIKeyID", h.computeRoute(middlewares, h.Delete)...) + router.Delete("/:phoneAPIKeyID/phones/:phoneID", h.computeRoute(middlewares, h.DeletePhone)...) +} + +// Store a new Phone API key +// @Summary Store phone API key +// @Description Creates a new phone API key which can be used to log in to the httpSMS app on your Android phone +// @Security ApiKeyAuth +// @Tags PhoneAPIKeys +// @Accept json +// @Produce json +// @Param payload body requests.PhoneAPIKeyStoreRequest true "Payload of new phone API key." +// @Success 200 {object} responses.Ok[*entities.PhoneAPIKey] +// @Failure 400 {object} responses.BadRequest +// @Failure 401 {object} responses.Unauthorized +// @Failure 422 {object} responses.UnprocessableEntity +// @Failure 500 {object} responses.InternalServerError +// @Router /api-keys [post] +func (h *PhoneAPIKeyHandler) Store(c *fiber.Ctx) error { + ctx, span := h.tracer.StartFromFiberCtx(c) + defer span.End() + + ctxLogger := h.tracer.CtxLogger(h.logger, span) + + var request requests.PhoneAPIKeyStoreRequest + if err := c.BodyParser(&request); err != nil { + msg := fmt.Sprintf("cannot marshall params [%s] into %T", c.OriginalURL(), request) + ctxLogger.Warn(stacktrace.Propagate(err, msg)) + return h.responseBadRequest(c, err) + } + + if errors := h.validator.ValidateStore(ctx, request.Sanitize()); len(errors) != 0 { + msg := fmt.Sprintf("validation errors [%s], while updating phones [%+#v]", spew.Sdump(errors), request) + ctxLogger.Warn(stacktrace.NewError(msg)) + return h.responseUnprocessableEntity(c, errors, "validation errors while updating phones") + } + + phone, err := h.service.Create(ctx, h.userFromContext(c), request.Name) + if err != nil { + msg := fmt.Sprintf("cannot update phones with params [%+#v]", request) + ctxLogger.Error(stacktrace.Propagate(err, msg)) + return h.responseInternalServerError(c) + } + + return h.responseOK(c, "phone updated successfully", phone) +} + +// Delete a phone API Key +// @Summary Delete a phone API key from the database. +// @Description Delete a phone API Key from the database and cannot be used for authentication anymore. +// @Security ApiKeyAuth +// @Tags PhoneAPIKeys +// @Accept json +// @Produce json +// @Param phoneAPIKeyID path string true "ID of the phone API key" default(32343a19-da5e-4b1b-a767-3298a73703ca) +// @Success 204 {object} responses.NoContent +// @Failure 400 {object} responses.BadRequest +// @Failure 401 {object} responses.Unauthorized +// @Failure 404 {object} responses.NotFound +// @Failure 422 {object} responses.UnprocessableEntity +// @Failure 500 {object} responses.InternalServerError +// @Router /messages/{phoneAPIKeyID} [delete] +func (h *PhoneAPIKeyHandler) Delete(c *fiber.Ctx) error { + ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) + defer span.End() + + phoneAPIKeyID := c.Params("phoneAPIKeyID") + if errors := h.validator.ValidateUUID(phoneAPIKeyID, "phoneAPIKeyID"); len(errors) != 0 { + msg := fmt.Sprintf("validation errors [%s], while deleting a phone API key with ID [%s]", spew.Sdump(errors), phoneAPIKeyID) + ctxLogger.Warn(stacktrace.NewError(msg)) + return h.responseUnprocessableEntity(c, errors, "validation errors while storing event") + } + + err := h.service.Delete(ctx, h.userIDFomContext(c), uuid.MustParse(phoneAPIKeyID)) + if stacktrace.GetCode(err) == repositories.ErrCodeNotFound { + return h.responseNotFound(c, fmt.Sprintf("cannot find phone API key with ID [%s]", phoneAPIKeyID)) + } + + if err != nil { + msg := fmt.Sprintf("cannot delete phone API key with ID [%s] for user with ID [%s]", phoneAPIKeyID, h.userIDFomContext(c)) + ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))) + return h.responseInternalServerError(c) + } + + return h.responseNoContent(c, "phone API key deleted successfully") +} + +// DeletePhone removes a phone from a phone API key +// @Summary Remove the association of a phone from the phone API key. +// @Description You will need to login again to the httpSMS app on your Android phone with a new phone API key. +// @Security ApiKeyAuth +// @Tags PhoneAPIKeys +// @Accept json +// @Produce json +// @Param phoneAPIKeyID path string true "ID of the phone API key" default(32343a19-da5e-4b1b-a767-3298a73703ca) +// @Param phoneID path string true "ID of the phone" default(32343a19-da5e-4b1b-a767-3298a73703ca) +// @Success 204 {object} responses.NoContent +// @Failure 400 {object} responses.BadRequest +// @Failure 401 {object} responses.Unauthorized +// @Failure 404 {object} responses.NotFound +// @Failure 422 {object} responses.UnprocessableEntity +// @Failure 500 {object} responses.InternalServerError +// @Router /messages/{phoneAPIKeyID}/phones/{phoneID} [delete] +func (h *PhoneAPIKeyHandler) DeletePhone(c *fiber.Ctx) error { + ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) + defer span.End() + + phoneAPIKeyID := c.Params("phoneAPIKeyID") + phoneID := c.Params("phoneID") + if errors := h.mergeErrors(h.validator.ValidateUUID(phoneAPIKeyID, "phoneAPIKeyID"), h.validator.ValidateUUID(phoneID, "phoneID")); len(errors) != 0 { + msg := fmt.Sprintf("validation errors [%s], while deleting a phone API key with ID [%s]", spew.Sdump(errors), phoneAPIKeyID) + ctxLogger.Warn(stacktrace.NewError(msg)) + return h.responseUnprocessableEntity(c, errors, "validation errors while storing event") + } + + err := h.service.RemovePhone(ctx, h.userIDFomContext(c), uuid.MustParse(phoneAPIKeyID), uuid.MustParse(phoneID)) + if stacktrace.GetCode(err) == repositories.ErrCodeNotFound { + return h.responseNotFound(c, fmt.Sprintf("cannot find phone with ID [%s] which is associated with phone API key with ID [%s]", phoneID, phoneAPIKeyID)) + } + + if err != nil { + msg := fmt.Sprintf("cannot remove phone with ID [%s] from phone API key with ID [%s] for user with ID [%s]", phoneID, phoneAPIKeyID, h.userIDFomContext(c)) + ctxLogger.Error(h.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg))) + return h.responseInternalServerError(c) + } + + return h.responseNoContent(c, "phone has been dissociated from phone API key successfully") +} diff --git a/api/pkg/handlers/webhook_handler.go b/api/pkg/handlers/webhook_handler.go index 41200856..72cb407a 100644 --- a/api/pkg/handlers/webhook_handler.go +++ b/api/pkg/handlers/webhook_handler.go @@ -111,7 +111,7 @@ func (h *WebhookHandler) Delete(c *fiber.Ctx) error { defer span.End() webhookID := c.Params("webhookID") - if errors := h.validator.ValidateUUID(ctx, webhookID, "webhookID"); len(errors) != 0 { + if errors := h.validator.ValidateUUID(webhookID, "webhookID"); len(errors) != 0 { msg := fmt.Sprintf("validation errors [%s], while deleting webhook with ID [%s]", spew.Sdump(errors), webhookID) ctxLogger.Warn(stacktrace.NewError(msg)) return h.responseUnprocessableEntity(c, errors, "validation errors while deleting webhook") diff --git a/api/pkg/repositories/gorm_phone_api_key_repository.go b/api/pkg/repositories/gorm_phone_api_key_repository.go index 7e88355e..378456e2 100644 --- a/api/pkg/repositories/gorm_phone_api_key_repository.go +++ b/api/pkg/repositories/gorm_phone_api_key_repository.go @@ -37,6 +37,26 @@ func NewGormPhoneAPIKeyRepository( } } +// Load an entities.Integration3CX based on the entities.UserID +func (repository *gormPhoneAPIKeyRepository) Load(ctx context.Context, userID entities.UserID, phoneAPIKeyID uuid.UUID) (*entities.PhoneAPIKey, error) { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + phoneAPIKey := new(entities.PhoneAPIKey) + err := repository.db.WithContext(ctx).Where("user_id = ?", userID).Where("id = ?", phoneAPIKeyID).First(&phoneAPIKey).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + msg := fmt.Sprintf("[%T] with ID [%s] for user with ID [%s] does not exist", phoneAPIKey, phoneAPIKeyID, userID) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, msg)) + } + + if err != nil { + msg := fmt.Sprintf("cannot load [%T] with ID [%s] for user with ID [%s]", phoneAPIKey, phoneAPIKeyID, userID) + return nil, repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + return phoneAPIKey, nil +} + func (repository *gormPhoneAPIKeyRepository) Create(ctx context.Context, phoneAPIKey *entities.PhoneAPIKey) error { ctx, span := repository.tracer.Start(ctx) defer span.End() @@ -77,7 +97,7 @@ func (repository *gormPhoneAPIKeyRepository) LoadAuthContext(ctx context.Context PhoneNumbers: phoneAPIKey.PhoneNumbers, } - if result := repository.cache.SetWithTTL(apiKey, authUser, 1, 2*time.Hour); !result { + if result := repository.cache.SetWithTTL(apiKey, authUser, 1, 1*time.Hour); !result { msg := fmt.Sprintf("cannot cache [%T] with ID [%s] and result [%t]", authUser, phoneAPIKey.ID, result) ctxLogger.Error(repository.tracer.WrapErrorSpan(span, stacktrace.NewError(msg))) } @@ -104,23 +124,21 @@ func (repository *gormPhoneAPIKeyRepository) Index(ctx context.Context, userID e return *phoneAPIKeys, nil } -func (repository *gormPhoneAPIKeyRepository) Delete(ctx context.Context, userID entities.UserID, phoneAPIKeyID uuid.UUID) error { +func (repository *gormPhoneAPIKeyRepository) Delete(ctx context.Context, phoneAPIKey *entities.PhoneAPIKey) error { ctx, span := repository.tracer.Start(ctx) defer span.End() - err := repository.db.WithContext(ctx). - Where("user_id = ?", userID). - Where("id = ?", phoneAPIKeyID). - Delete(&entities.PhoneAPIKey{}).Error + err := repository.db.WithContext(ctx).Delete(phoneAPIKey).Error if err != nil { - msg := fmt.Sprintf("cannot delete phone API key with ID [%s] and userID [%s]", phoneAPIKeyID, userID) + msg := fmt.Sprintf("cannot delete phone API key with ID [%s] and userID [%s]", phoneAPIKey.ID, phoneAPIKey.UserID) return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } + repository.cache.Del(phoneAPIKey.APIKey) return nil } -func (repository *gormPhoneAPIKeyRepository) AddPhone(ctx context.Context, authContext entities.AuthContext, phone *entities.Phone) error { +func (repository *gormPhoneAPIKeyRepository) AddPhone(ctx context.Context, authContext entities.AuthContext, phoneID uuid.UUID, phoneNumber string) error { ctx, span := repository.tracer.Start(ctx) defer span.End() @@ -132,17 +150,19 @@ WHERE array_position(phone_ids, ?) IS NULL AND id = ?; ` err := repository.db.WithContext(ctx). - Raw(query, (entities.PhoneAPIKey{}).TableName(), phone.ID, phone.PhoneNumber, phone.ID, *authContext.PhoneAPIKeyID). + Raw(query, (entities.PhoneAPIKey{}).TableName(), phoneID, phoneNumber, phoneID, *authContext.PhoneAPIKeyID). Error if err != nil { - msg := fmt.Sprintf("cannot add phone with ID [%s] to phone API key with ID [%s]", phone.ID, *authContext.PhoneAPIKeyID) + msg := fmt.Sprintf("cannot add phone with ID [%s] to phone API key with ID [%s]", phoneID, *authContext.PhoneAPIKeyID) return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } + repository.cache.Clear() + return nil } -func (repository *gormPhoneAPIKeyRepository) RemovePhone(ctx context.Context, authContext entities.AuthContext, phone *entities.Phone) error { +func (repository *gormPhoneAPIKeyRepository) RemovePhone(ctx context.Context, phoneAPIKey *entities.PhoneAPIKey, phone *entities.Phone) error { ctx, span := repository.tracer.Start(ctx) defer span.End() @@ -153,13 +173,15 @@ SET phone_ids = array_remove(phone_ids, ?), WHERE id = ?; ` err := repository.db.WithContext(ctx). - Raw(query, (entities.PhoneAPIKey{}).TableName(), phone.ID, phone.PhoneNumber, *authContext.PhoneAPIKeyID). + Raw(query, (entities.PhoneAPIKey{}).TableName(), phone.ID, phone.PhoneNumber, phoneAPIKey.ID). Error if err != nil { - msg := fmt.Sprintf("cannot remove phone with ID [%s] to phone API key with ID [%s]", phone.ID, *authContext.PhoneAPIKeyID) + msg := fmt.Sprintf("cannot remove phone with ID [%s] from phone API key with ID [%s]", phone.ID, phoneAPIKey.ID) return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } + repository.cache.Clear() + return nil } diff --git a/api/pkg/repositories/phone_api_key_repository.go b/api/pkg/repositories/phone_api_key_repository.go index c443d521..52828980 100644 --- a/api/pkg/repositories/phone_api_key_repository.go +++ b/api/pkg/repositories/phone_api_key_repository.go @@ -13,6 +13,9 @@ type PhoneAPIKeyRepository interface { // Create a new entities.PhoneAPIKey Create(ctx context.Context, phone *entities.PhoneAPIKey) error + // Load an entities.PhoneAPIKey by userID and phoneAPIKeyID + Load(ctx context.Context, userID entities.UserID, phoneAPIKeyID uuid.UUID) (*entities.PhoneAPIKey, error) + // LoadAuthContext fetches an entities.AuthContext by apiKey LoadAuthContext(ctx context.Context, apiKey string) (entities.AuthContext, error) @@ -20,10 +23,13 @@ type PhoneAPIKeyRepository interface { Index(ctx context.Context, userID entities.UserID, params IndexParams) ([]*entities.PhoneAPIKey, error) // Delete an entities.PhoneAPIKey - Delete(ctx context.Context, userID entities.UserID, phoneAPIKeyID uuid.UUID) error + Delete(ctx context.Context, phoneAPIKey *entities.PhoneAPIKey) error + + // AddPhone adds an entities.Phone to an entities.PhoneAPIKey + AddPhone(ctx context.Context, authContext entities.AuthContext, phoneID uuid.UUID, phoneNumber string) error - // AddPhone an entities.Phone to an entities.PhoneAPIKey - AddPhone(ctx context.Context, authContext entities.AuthContext, phone *entities.Phone) error + // RemovePhone removes an entities.Phone to an entities.PhoneAPIKey + RemovePhone(ctx context.Context, phoneAPIKey *entities.PhoneAPIKey, phone *entities.Phone) error // DeleteAllForUser deletes all entities.PhoneAPIKey for a user DeleteAllForUser(ctx context.Context, userID entities.UserID) error diff --git a/api/pkg/requests/phone_api_key_store_request.go b/api/pkg/requests/phone_api_key_store_request.go new file mode 100644 index 00000000..e7df2484 --- /dev/null +++ b/api/pkg/requests/phone_api_key_store_request.go @@ -0,0 +1,13 @@ +package requests + +// PhoneAPIKeyStoreRequest is the payload for storing a phone API key +type PhoneAPIKeyStoreRequest struct { + request + Name string `json:"name" example:"My Phone API Key"` +} + +// Sanitize sets defaults to MessageReceive +func (input *PhoneAPIKeyStoreRequest) Sanitize() PhoneAPIKeyStoreRequest { + input.Name = input.sanitizeAddress(input.Name) + return *input +} diff --git a/api/pkg/services/phone_api_key_service.go b/api/pkg/services/phone_api_key_service.go new file mode 100644 index 00000000..fd8f2f5e --- /dev/null +++ b/api/pkg/services/phone_api_key_service.go @@ -0,0 +1,128 @@ +package services + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "time" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/google/uuid" + "github.com/palantir/stacktrace" +) + +// PhoneAPIKeyService is responsible for managing entities.PhoneAPIKey +type PhoneAPIKeyService struct { + service + logger telemetry.Logger + tracer telemetry.Tracer + phoneRepository repositories.PhoneRepository + repository repositories.PhoneAPIKeyRepository +} + +// NewPhoneAPIKeyService creates a new PhoneAPIKeyService +func NewPhoneAPIKeyService( + logger telemetry.Logger, + tracer telemetry.Tracer, + phoneRepository repositories.PhoneRepository, + repository repositories.PhoneAPIKeyRepository, +) *PhoneAPIKeyService { + return &PhoneAPIKeyService{ + logger: logger.WithService(fmt.Sprintf("%T", &PhoneAPIKeyService{})), + tracer: tracer, + phoneRepository: phoneRepository, + repository: repository, + } +} + +// Create a new entities.PhoneAPIKey +func (service *PhoneAPIKeyService) Create(ctx context.Context, authContext entities.AuthContext, name string) (*entities.PhoneAPIKey, error) { + ctx, span := service.tracer.Start(ctx) + defer span.End() + + apiKey, err := service.generateAPIKey(64) + if err != nil { + return nil, stacktrace.Propagate(err, "cannot generate API key") + } + + phoneAPIKey := &entities.PhoneAPIKey{ + ID: uuid.New(), + Name: name, + UserID: authContext.ID, + UserEmail: authContext.Email, + PhoneNumbers: nil, + PhoneIDs: nil, + APIKey: apiKey, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + + if err = service.repository.Create(ctx, phoneAPIKey); err != nil { + msg := fmt.Sprintf("cannot create PhoneAPIKey for user [%s]", authContext.ID) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + return phoneAPIKey, nil +} + +// Delete an entities.PhoneAPIKey +func (service *PhoneAPIKeyService) Delete(ctx context.Context, userID entities.UserID, phoneAPIKeyID uuid.UUID) error { + ctx, span := service.tracer.Start(ctx) + defer span.End() + + phoneAPIKey, err := service.repository.Load(ctx, userID, phoneAPIKeyID) + if err != nil { + msg := fmt.Sprintf("cannot load [%T] with ID [%s] for user [%s]", &entities.PhoneAPIKey{}, phoneAPIKeyID, userID.String()) + return stacktrace.Propagate(err, msg) + } + + if err = service.repository.Delete(ctx, phoneAPIKey); err != nil { + msg := fmt.Sprintf("cannot delete [%T] with ID [%s] for user [%s]", phoneAPIKey, phoneAPIKey.ID, phoneAPIKey.UserID) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + return nil +} + +// RemovePhone removes the phone from the phone API key +func (service *PhoneAPIKeyService) RemovePhone(ctx context.Context, userID entities.UserID, phoneAPIKeyID uuid.UUID, phoneID uuid.UUID) error { + ctx, span := service.tracer.Start(ctx) + defer span.End() + + phone, err := service.phoneRepository.LoadByID(ctx, userID, phoneID) + if err != nil { + msg := fmt.Sprintf("cannot load [%T] with ID [%s] for user [%s]", &entities.Phone{}, phoneID, userID.String()) + return stacktrace.Propagate(err, msg) + } + + phoneAPIKey, err := service.repository.Load(ctx, userID, phoneAPIKeyID) + if err != nil { + msg := fmt.Sprintf("cannot load [%T] with ID [%s] for user [%s]", &entities.PhoneAPIKey{}, phoneAPIKeyID, userID.String()) + return stacktrace.Propagate(err, msg) + } + + if err = service.repository.RemovePhone(ctx, phoneAPIKey, phone); err != nil { + msg := fmt.Sprintf("cannot remove [%T] with ID [%s] from phone API key with ID [%s] for user [%s]", phone, phone.ID, phoneAPIKey.ID, userID) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + return nil +} + +func (service *PhoneAPIKeyService) generateRandomBytes(n int) ([]byte, error) { + b := make([]byte, n) + // Note that err == nil only if we read len(b) bytes. + if _, err := rand.Read(b); err != nil { + return nil, stacktrace.Propagate(err, fmt.Sprintf("cannot generate [%d] random bytes", n)) + } + + return b, nil +} + +func (service *PhoneAPIKeyService) generateAPIKey(n int) (string, error) { + b, err := service.generateRandomBytes(n) + return base64.URLEncoding.EncodeToString(b)[0:n], stacktrace.Propagate(err, fmt.Sprintf("cannot generate [%s] random bytes", n)) +} diff --git a/api/pkg/validators/phone_api_key_handler_validator.go b/api/pkg/validators/phone_api_key_handler_validator.go new file mode 100644 index 00000000..85b7b4f1 --- /dev/null +++ b/api/pkg/validators/phone_api_key_handler_validator.go @@ -0,0 +1,44 @@ +package validators + +import ( + "context" + "fmt" + "net/url" + + "github.com/NdoleStudio/httpsms/pkg/requests" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/thedevsaddam/govalidator" +) + +// PhoneAPIKeyHandlerValidator validates models used in handlers.PhoneAPIKeyHandler +type PhoneAPIKeyHandlerValidator struct { + validator + logger telemetry.Logger + tracer telemetry.Tracer +} + +// NewPhoneAPIKeyHandlerValidator creates a new handlers.PhoneAPIKeyHandler validator +func NewPhoneAPIKeyHandlerValidator( + logger telemetry.Logger, + tracer telemetry.Tracer, +) (v *PhoneHandlerValidator) { + return &PhoneHandlerValidator{ + logger: logger.WithService(fmt.Sprintf("%T", v)), + tracer: tracer, + } +} + +// ValidateStore validates requests.PhoneAPIKeyStoreRequest +func (validator *PhoneAPIKeyHandlerValidator) ValidateStore(_ context.Context, request requests.PhoneAPIKeyStoreRequest) url.Values { + v := govalidator.New(govalidator.Options{ + Data: &request, + Rules: govalidator.MapData{ + "name": []string{ + "min:1", + "max:60", + }, + }, + }) + + return v.ValidateStruct() +} diff --git a/api/pkg/validators/validator.go b/api/pkg/validators/validator.go index 4200f592..7ca86f61 100644 --- a/api/pkg/validators/validator.go +++ b/api/pkg/validators/validator.go @@ -1,7 +1,6 @@ package validators import ( - "context" "fmt" "net/url" "regexp" @@ -144,7 +143,7 @@ func init() { } // ValidateUUID that the payload is a UUID -func (validator *validator) ValidateUUID(_ context.Context, ID string, name string) url.Values { +func (validator *validator) ValidateUUID(ID string, name string) url.Values { request := map[string]string{ name: ID, } From da1d07b2654eb0f5e2bf0832bebf8f08b2bfcd7b Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Fri, 18 Apr 2025 16:42:56 +0200 Subject: [PATCH 003/336] only delete the editted api key --- api/pkg/repositories/gorm_phone_api_key_repository.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/pkg/repositories/gorm_phone_api_key_repository.go b/api/pkg/repositories/gorm_phone_api_key_repository.go index 378456e2..6feecd2a 100644 --- a/api/pkg/repositories/gorm_phone_api_key_repository.go +++ b/api/pkg/repositories/gorm_phone_api_key_repository.go @@ -180,7 +180,7 @@ WHERE id = ?; return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } - repository.cache.Clear() + repository.cache.Del(phoneAPIKey.APIKey) return nil } From ef77a32096e8b49f600d088dc16c524482097e66 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Mon, 21 Apr 2025 11:39:48 +0200 Subject: [PATCH 004/336] Add automigrate --- api/pkg/di/container.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index 84fa7ea5..0ec2d426 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -356,6 +356,10 @@ ALTER TABLE discords ADD CONSTRAINT IF NOT EXISTS uni_discords_server_id CHECK ( container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.Integration3CX{}))) } + if err = db.AutoMigrate(&entities.PhoneAPIKey{}); err != nil { + container.logger.Fatal(stacktrace.Propagate(err, fmt.Sprintf("cannot migrate %T", &entities.PhoneAPIKey{}))) + } + return container.db } From 88599249400c4a4276a23d74f454d7b846212ac6 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Tue, 22 Apr 2025 16:31:28 +0200 Subject: [PATCH 005/336] Create the API's for handing phone API keys --- api/pkg/di/container.go | 97 +++++++++++++--- api/pkg/events/phone_updated_event.go | 11 +- api/pkg/handlers/billing_handler.go | 6 +- api/pkg/handlers/bulk_message_handler.go | 4 +- api/pkg/handlers/events_handler.go | 4 +- api/pkg/handlers/handler.go | 19 ++++ api/pkg/handlers/heartbeat_handler.go | 19 +++- api/pkg/handlers/message_handler.go | 45 +++++--- api/pkg/handlers/message_thread_handler.go | 2 +- api/pkg/handlers/phone_api_key_handler.go | 19 ++-- api/pkg/handlers/phone_handler.go | 56 +++++++++- api/pkg/handlers/user_handler.go | 16 +-- api/pkg/handlers/webhook_handler.go | 11 +- api/pkg/listeners/phone_api_key_listener.go | 104 ++++++++++++++++++ .../middlewares/api_key_auth_middleware.go | 5 +- .../phone_api_key_auth_middleware.go | 37 +++++++ .../repositories/gorm_message_repository.go | 13 ++- .../gorm_phone_api_key_repository.go | 66 +++++++++-- api/pkg/repositories/message_repository.go | 2 +- .../repositories/phone_api_key_repository.go | 5 +- .../requests/message_outstanding_request.go | 11 +- api/pkg/requests/phone_fcm_token_request.go | 40 +++++++ api/pkg/services/message_service.go | 11 +- api/pkg/services/phone_api_key_service.go | 63 ++++++++++- api/pkg/services/phone_service.go | 74 +++++++++++-- .../phone_api_key_handler_validator.go | 4 +- api/pkg/validators/phone_handler_validator.go | 23 ++++ 27 files changed, 648 insertions(+), 119 deletions(-) create mode 100644 api/pkg/listeners/phone_api_key_listener.go create mode 100644 api/pkg/middlewares/phone_api_key_auth_middleware.go create mode 100644 api/pkg/requests/phone_fcm_token_request.go diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index 0ec2d426..51a0b303 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -145,6 +145,9 @@ func NewContainer(projectID string, version string) (container *Container) { container.RegisterDiscordRoutes() container.RegisterDiscordListeners() + container.RegisterPhoneAPIKeyRoutes() + container.RegisterPhoneAPIKeyListeners() + container.RegisterMarketingListeners() // this has to be last since it registers the /* route @@ -191,18 +194,18 @@ func (container *Container) BearerAPIKeyMiddleware() fiber.Handler { return middlewares.BearerAPIKeyAuth(container.Logger(), container.Tracer(), container.UserRepository()) } +// PhoneAPIKeyMiddleware creates a new instance of middlewares.BearerAPIKeyAuth +func (container *Container) PhoneAPIKeyMiddleware() fiber.Handler { + container.logger.Debug("creating middlewares.PhoneAPIKeyMiddleware") + return middlewares.PhoneAPIKeyAuth(container.Logger(), container.Tracer(), container.PhoneAPIKeyRepository()) +} + // AuthenticatedMiddleware creates a new instance of middlewares.Authenticated func (container *Container) AuthenticatedMiddleware() fiber.Handler { container.logger.Debug("creating middlewares.Authenticated") return middlewares.Authenticated(container.Tracer()) } -// AuthRouter creates router for authenticated requests -func (container *Container) AuthRouter() fiber.Router { - container.logger.Debug("creating authRouter") - return container.App().Group("v1").Use(container.AuthenticatedMiddleware()) -} - // Logger creates a new instance of telemetry.Logger func (container *Container) Logger(skipFrameCount ...int) telemetry.Logger { container.logger.Debug("creating telemetry.Logger") @@ -686,6 +689,17 @@ func (container *Container) MessageRepository() (repository repositories.Message ) } +// PhoneAPIKeyRepository creates a new instance of repositories.PhoneAPIKeyRepository +func (container *Container) PhoneAPIKeyRepository() (repository repositories.PhoneAPIKeyRepository) { + container.logger.Debug("creating GORM repositories.PhoneAPIKeyRepository") + return repositories.NewGormPhoneAPIKeyRepository( + container.Logger(), + container.Tracer(), + container.DB(), + container.UserRistrettoCache(), + ) +} + // Integration3CXRepository creates a new instance of repositories.Integration3CxRepository func (container *Container) Integration3CXRepository() (repository repositories.Integration3CxRepository) { container.logger.Debug("creating GORM repositories.Integration3CxRepository") @@ -1072,6 +1086,18 @@ func (container *Container) Integration3CXHandler() (handler *handlers.Integrati ) } +// PhoneAPIKeyHandler creates a new instance of handlers.PhoneAPIKeyHandler +func (container *Container) PhoneAPIKeyHandler() (handler *handlers.PhoneAPIKeyHandler) { + container.logger.Debug(fmt.Sprintf("creating %T", handler)) + + return handlers.NewPhoneAPIKeyHandler( + container.Logger(), + container.Tracer(), + container.PhoneAPIKeyHandlerValidator(), + container.PhoneAPIKeyService(), + ) +} + // DiscordHandler creates a new instance of handlers.DiscordHandler func (container *Container) DiscordHandler() (handler *handlers.DiscordHandler) { container.logger.Debug(fmt.Sprintf("creating %T", handler)) @@ -1097,6 +1123,15 @@ func (container *Container) LemonsqueezyHandlerValidator() (validator *validator ) } +// PhoneAPIKeyHandlerValidator creates a new instance of validators.PhoneAPIKeyHandlerValidator +func (container *Container) PhoneAPIKeyHandlerValidator() (validator *validators.PhoneAPIKeyHandlerValidator) { + container.logger.Debug(fmt.Sprintf("creating %T", validator)) + return validators.NewPhoneAPIKeyHandlerValidator( + container.Logger(), + container.Tracer(), + ) +} + // LemonsqueezyClient creates a new instance of lemonsqueezy.Client func (container *Container) LemonsqueezyClient() (client *lemonsqueezy.Client) { container.logger.Debug(fmt.Sprintf("creating %T", client)) @@ -1129,6 +1164,12 @@ func (container *Container) RegisterIntegration3CXRoutes() { container.Integration3CXHandler().RegisterRoutes(container.App(), container.BearerAPIKeyMiddleware(), container.AuthenticatedMiddleware()) } +// RegisterPhoneAPIKeyRoutes registers routes for the /phone-api-key prefix +func (container *Container) RegisterPhoneAPIKeyRoutes() { + container.logger.Debug(fmt.Sprintf("registering [%T] routes", &handlers.Integration3CXHandler{})) + container.PhoneAPIKeyHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware()) +} + // RegisterDiscordRoutes registers routes for the /discord prefix func (container *Container) RegisterDiscordRoutes() { container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.DiscordHandler{})) @@ -1261,6 +1302,20 @@ func (container *Container) RegisterIntegration3CXListeners() { } } +// RegisterPhoneAPIKeyListeners registers event listeners for listeners.PhoneAPIKeyListener +func (container *Container) RegisterPhoneAPIKeyListeners() { + container.logger.Debug(fmt.Sprintf("registering listeners for %T", listeners.PhoneAPIKeyListener{})) + _, routes := listeners.NewPhoneAPIKeyListener( + container.Logger(), + container.Tracer(), + container.PhoneAPIKeyService(), + ) + + for event, handler := range routes { + container.EventDispatcher().Subscribe(event, handler) + } +} + // RegisterWebhookListeners registers event listeners for listeners.WebhookListener func (container *Container) RegisterWebhookListeners() { container.logger.Debug(fmt.Sprintf("registering listeners for %T", listeners.WebhookListener{})) @@ -1287,6 +1342,17 @@ func (container *Container) MessageService() (service *services.MessageService) ) } +// PhoneAPIKeyService creates a new instance of services.PhoneAPIKeyService +func (container *Container) PhoneAPIKeyService() (service *services.PhoneAPIKeyService) { + container.logger.Debug(fmt.Sprintf("creating %T", service)) + return services.NewPhoneAPIKeyService( + container.Logger(), + container.Tracer(), + container.PhoneRepository(), + container.PhoneAPIKeyRepository(), + ) +} + // NotificationService creates a new instance of services.PhoneNotificationService func (container *Container) NotificationService() (service *services.PhoneNotificationService) { container.logger.Debug(fmt.Sprintf("creating %T", service)) @@ -1303,31 +1369,33 @@ func (container *Container) NotificationService() (service *services.PhoneNotifi // RegisterMessageRoutes registers routes for the /messages prefix func (container *Container) RegisterMessageRoutes() { container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.MessageHandler{})) - container.MessageHandler().RegisterRoutes(container.AuthRouter()) + container.MessageHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware()) + container.MessageHandler().RegisterPhoneAPIKeyRoutes(container.App(), container.PhoneAPIKeyMiddleware(), container.AuthenticatedMiddleware()) } // RegisterBulkMessageRoutes registers routes for the /bulk-messages prefix func (container *Container) RegisterBulkMessageRoutes() { container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.BulkMessageHandler{})) - container.BulkMessageHandler().RegisterRoutes(container.AuthRouter()) + container.BulkMessageHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware()) } // RegisterMessageThreadRoutes registers routes for the /message-threads prefix func (container *Container) RegisterMessageThreadRoutes() { container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.MessageThreadHandler{})) - container.MessageThreadHandler().RegisterRoutes(container.AuthRouter()) + container.MessageThreadHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware()) } // RegisterHeartbeatRoutes registers routes for the /heartbeats prefix func (container *Container) RegisterHeartbeatRoutes() { container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.HeartbeatHandler{})) - container.HeartbeatHandler().RegisterRoutes(container.AuthRouter()) + container.HeartbeatHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware()) + container.HeartbeatHandler().RegisterPhoneAPIKeyRoutes(container.App(), container.PhoneAPIKeyMiddleware(), container.AuthenticatedMiddleware()) } // RegisterBillingRoutes registers routes for the /billing prefix func (container *Container) RegisterBillingRoutes() { container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.BillingHandler{})) - container.BillingHandler().RegisterRoutes(container.AuthRouter()) + container.BillingHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware()) } // RegisterWebhookRoutes registers routes for the /webhooks prefix @@ -1339,19 +1407,20 @@ func (container *Container) RegisterWebhookRoutes() { // RegisterPhoneRoutes registers routes for the /phone prefix func (container *Container) RegisterPhoneRoutes() { container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.PhoneHandler{})) - container.PhoneHandler().RegisterRoutes(container.AuthRouter()) + container.PhoneHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware()) + container.PhoneHandler().RegisterPhoneAPIKeyRoutes(container.App(), container.PhoneAPIKeyMiddleware(), container.AuthenticatedMiddleware()) } // RegisterUserRoutes registers routes for the /users prefix func (container *Container) RegisterUserRoutes() { container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.UserHandler{})) - container.UserHandler().RegisterRoutes(container.AuthRouter()) + container.UserHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware()) } // RegisterEventRoutes registers routes for the /events prefix func (container *Container) RegisterEventRoutes() { container.logger.Debug(fmt.Sprintf("registering %T routes", &handlers.EventsHandler{})) - container.EventsHandler().RegisterRoutes(container.AuthRouter()) + container.EventsHandler().RegisterRoutes(container.App(), container.AuthenticatedMiddleware()) } // RegisterSwaggerRoutes registers routes for swagger diff --git a/api/pkg/events/phone_updated_event.go b/api/pkg/events/phone_updated_event.go index 88fc6c9d..082aaa3a 100644 --- a/api/pkg/events/phone_updated_event.go +++ b/api/pkg/events/phone_updated_event.go @@ -12,9 +12,10 @@ const EventTypePhoneUpdated = "phone.updated" // PhoneUpdatedPayload is the payload of the EventTypePhoneUpdated event type PhoneUpdatedPayload struct { - PhoneID uuid.UUID `json:"phone_id"` - UserID entities.UserID `json:"user_id"` - Timestamp time.Time `json:"timestamp"` - Owner string `json:"owner"` - SIM entities.SIM `json:"sim"` + PhoneID uuid.UUID `json:"phone_id"` + UserID entities.UserID `json:"user_id"` + PhoneAPIKeyID *uuid.UUID `json:"phone_api_key_id"` + Timestamp time.Time `json:"timestamp"` + Owner string `json:"owner"` + SIM entities.SIM `json:"sim"` } diff --git a/api/pkg/handlers/billing_handler.go b/api/pkg/handlers/billing_handler.go index cd6d5713..4c0da0c2 100644 --- a/api/pkg/handlers/billing_handler.go +++ b/api/pkg/handlers/billing_handler.go @@ -37,9 +37,9 @@ func NewBillingHandler( } // RegisterRoutes registers the routes for the MessageHandler -func (h *BillingHandler) RegisterRoutes(router fiber.Router) { - router.Get("/billing/usage-history", h.UsageHistory) - router.Get("/billing/usage", h.Usage) +func (h *BillingHandler) RegisterRoutes(router fiber.Router, middlewares ...fiber.Handler) { + router.Get("/billing/usage-history", h.computeRoute(middlewares, h.UsageHistory)...) + router.Get("/billing/usage", h.computeRoute(middlewares, h.Usage)...) } // UsageHistory returns the usage history of a user diff --git a/api/pkg/handlers/bulk_message_handler.go b/api/pkg/handlers/bulk_message_handler.go index fa3cce82..d29a907d 100644 --- a/api/pkg/handlers/bulk_message_handler.go +++ b/api/pkg/handlers/bulk_message_handler.go @@ -43,8 +43,8 @@ func NewBulkMessageHandler( } // RegisterRoutes registers the routes for the MessageHandler -func (h *BulkMessageHandler) RegisterRoutes(router fiber.Router) { - router.Post("/bulk-messages", h.Store) +func (h *BulkMessageHandler) RegisterRoutes(router fiber.Router, middlewares ...fiber.Handler) { + router.Post("/v1/bulk-messages", h.computeRoute(middlewares, h.Store)...) } // Store sends bulk SMS messages from a CSV file. diff --git a/api/pkg/handlers/events_handler.go b/api/pkg/handlers/events_handler.go index 204fcc35..16d0325d 100644 --- a/api/pkg/handlers/events_handler.go +++ b/api/pkg/handlers/events_handler.go @@ -37,8 +37,8 @@ func NewEventsHandler( } // RegisterRoutes registers the routes for the MessageHandler -func (h *EventsHandler) RegisterRoutes(router fiber.Router) { - router.Post("/events", h.Dispatch) +func (h *EventsHandler) RegisterRoutes(router fiber.Router, middlewares ...fiber.Handler) { + router.Post("/v1/events", h.computeRoute(middlewares, h.Dispatch)...) } // Dispatch a cloud event diff --git a/api/pkg/handlers/handler.go b/api/pkg/handlers/handler.go index 55dfb353..e6d4cc09 100644 --- a/api/pkg/handlers/handler.go +++ b/api/pkg/handlers/handler.go @@ -1,7 +1,10 @@ package handlers import ( + "fmt" "net/url" + "slices" + "strings" "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/middlewares" @@ -35,6 +38,14 @@ func (h *handler) responseUnauthorized(c *fiber.Ctx) error { }) } +func (h *handler) responsePhoneAPIKeyUnauthorized(c *fiber.Ctx, owner string, authCtx entities.AuthContext) error { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "status": "error", + "message": "You are not authorized to carry out the request for this phone number", + "data": fmt.Sprintf("The phone API key is does not have permission to carry out actions on the phone number [%s]. The API key is only configured for these phone numbers [%s]", owner, strings.Join(authCtx.PhoneNumbers, ",")), + }) +} + func (h *handler) responseForbidden(c *fiber.Ctx) error { return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ "status": "error", @@ -127,3 +138,11 @@ func (h *handler) mergeErrors(errors ...url.Values) url.Values { } return result } + +func (h *handler) authorizePhoneAPIKey(c *fiber.Ctx, phoneNumber string) bool { + user := h.userFromContext(c) + if user.PhoneAPIKeyID == nil { + return true + } + return slices.Contains(user.PhoneNumbers, phoneNumber) +} diff --git a/api/pkg/handlers/heartbeat_handler.go b/api/pkg/handlers/heartbeat_handler.go index dc87d39d..e7fb7114 100644 --- a/api/pkg/handlers/heartbeat_handler.go +++ b/api/pkg/handlers/heartbeat_handler.go @@ -39,10 +39,14 @@ func NewHeartbeatHandler( } } -// RegisterRoutes registers the routes for the MessageHandler -func (h *HeartbeatHandler) RegisterRoutes(router fiber.Router) { - router.Get("/heartbeats", h.Index) - router.Post("/heartbeats", h.Store) +// RegisterRoutes registers the routes for the HeartbeatHandler +func (h *HeartbeatHandler) RegisterRoutes(router fiber.Router, middlewares ...fiber.Handler) { + router.Get("/heartbeats", h.computeRoute(middlewares, h.Index)...) +} + +// RegisterPhoneAPIKeyRoutes registers the routes for the HeartbeatHandler +func (h *HeartbeatHandler) RegisterPhoneAPIKeyRoutes(router fiber.Router, middlewares ...fiber.Handler) { + router.Post("/heartbeats", h.computeRoute(middlewares, h.Store)...) } // Index returns the heartbeats of a phone number @@ -124,6 +128,13 @@ func (h *HeartbeatHandler) Store(c *fiber.Ctx) error { return h.responseUnprocessableEntity(c, errors, "validation errors while storing heartbeat") } + for _, phoneNumber := range request.PhoneNumbers { + if !h.authorizePhoneAPIKey(c, phoneNumber) { + ctxLogger.Warn(stacktrace.NewError(fmt.Sprintf("phone API Key ID [%s] is not authorized to store heartbeat for phone number [%s]", h.userFromContext(c).PhoneAPIKeyID, phoneNumber))) + return h.responsePhoneAPIKeyUnauthorized(c, phoneNumber, h.userFromContext(c)) + } + } + params := request.ToStoreParams(h.userFromContext(c), c.OriginalURL(), c.Get("X-Client-Version")) wg := sync.WaitGroup{} diff --git a/api/pkg/handlers/message_handler.go b/api/pkg/handlers/message_handler.go index 55e574c3..758f4744 100644 --- a/api/pkg/handlers/message_handler.go +++ b/api/pkg/handlers/message_handler.go @@ -48,16 +48,20 @@ func NewMessageHandler( } // RegisterRoutes registers the routes for the MessageHandler -func (h *MessageHandler) RegisterRoutes(router fiber.Router) { - router.Post("/messages/send", h.PostSend) - router.Post("/messages/bulk-send", h.BulkSend) - router.Post("/messages/receive", h.PostReceive) - router.Post("/messages/calls/missed", h.PostCallMissed) - router.Get("/messages/outstanding", h.GetOutstanding) - router.Get("/messages", h.Index) - router.Get("/messages/search", h.Search) - router.Post("/messages/:messageID/events", h.PostEvent) - router.Delete("/messages/:messageID", h.Delete) +func (h *MessageHandler) RegisterRoutes(router fiber.Router, middlewares ...fiber.Handler) { + router.Post("/v1/messages/send", h.computeRoute(middlewares, h.PostSend)...) + router.Post("/v1/messages/bulk-send", h.computeRoute(middlewares, h.BulkSend)...) + router.Get("/v1/messages", h.computeRoute(middlewares, h.Index)...) + router.Get("/v1/messages/search", h.computeRoute(middlewares, h.Search)...) + router.Delete("/v1/messages/:messageID", h.computeRoute(middlewares, h.Delete)...) +} + +// RegisterPhoneAPIKeyRoutes registers the routes for the MessageHandler +func (h *MessageHandler) RegisterPhoneAPIKeyRoutes(router fiber.Router, middlewares ...fiber.Handler) { + router.Post("/v1/messages/:messageID/events", h.computeRoute(middlewares, h.PostEvent)...) + router.Post("/v1/messages/receive", h.computeRoute(middlewares, h.PostReceive)...) + router.Post("/v1/messages/calls/missed", h.computeRoute(middlewares, h.PostCallMissed)...) + router.Get("/v1/messages/outstanding", h.computeRoute(middlewares, h.GetOutstanding)...) } // PostSend a new entities.Message @@ -201,11 +205,11 @@ func (h *MessageHandler) GetOutstanding(c *fiber.Ctx) error { return h.responseUnprocessableEntity(c, errors, "validation errors while fetching outstanding messages") } - message, err := h.service.GetOutstanding(ctx, request.ToGetOutstandingParams(c.Path(), h.userIDFomContext(c), timestamp)) + message, err := h.service.GetOutstanding(ctx, request.ToGetOutstandingParams(c.Path(), h.userFromContext(c), timestamp)) if stacktrace.GetCode(err) == repositories.ErrCodeNotFound { - msg := fmt.Sprintf("outstanding message with id [%s] already fetched", request.MessageID) + msg := fmt.Sprintf("Cannot find outstanding message with ID [%s]", request.MessageID) ctxLogger.Warn(stacktrace.Propagate(err, msg)) - return h.responseNotFound(c, "outstanding message already processed") + return h.responseNotFound(c, msg) } if err != nil { @@ -315,6 +319,11 @@ func (h *MessageHandler) PostEvent(c *fiber.Ctx) error { return h.responseInternalServerError(c) } + if !h.authorizePhoneAPIKey(c, message.Owner) { + ctxLogger.Warn(stacktrace.NewError(fmt.Sprintf("user with ID [%s] is not authorized to send event for message with ID [%s]", h.userIDFomContext(c), request.MessageID))) + return h.responsePhoneAPIKeyUnauthorized(c, message.Owner, h.userFromContext(c)) + } + message, err = h.service.StoreEvent(ctx, message, request.ToMessageStoreEventParams(c.OriginalURL())) if err != nil { msg := fmt.Sprintf("cannot store event for message [%s] with paylod [%s]", request.MessageID, c.Body()) @@ -362,6 +371,11 @@ func (h *MessageHandler) PostReceive(c *fiber.Ctx) error { return h.responsePaymentRequired(c, *msg) } + if !h.authorizePhoneAPIKey(c, request.To) { + ctxLogger.Warn(stacktrace.NewError(fmt.Sprintf("user with ID [%s] is not authorized to receive message to phone number [%s]", h.userIDFomContext(c), request.To))) + return h.responsePhoneAPIKeyUnauthorized(c, request.To, h.userFromContext(c)) + } + message, err := h.service.ReceiveMessage(ctx, request.ToMessageReceiveParams(h.userIDFomContext(c), c.OriginalURL())) if err != nil { msg := fmt.Sprintf("cannot receive message with paylod [%s]", c.Body()) @@ -454,6 +468,11 @@ func (h *MessageHandler) PostCallMissed(c *fiber.Ctx) error { return h.responseUnprocessableEntity(c, errors, "validation errors while storing missed call event") } + if !h.authorizePhoneAPIKey(c, request.To) { + ctxLogger.Warn(stacktrace.NewError(fmt.Sprintf("user with ID [%s] is not authorized to register missed phone call for phone number [%s]", h.userIDFomContext(c), request.To))) + return h.responsePhoneAPIKeyUnauthorized(c, request.To, h.userFromContext(c)) + } + message, err := h.service.RegisterMissedCall(ctx, request.ToCallMissedParams(h.userIDFomContext(c), c.OriginalURL())) if err != nil { msg := fmt.Sprintf("cannot store missed call event for user [%s] with paylod [%s]", h.userIDFomContext(c), c.Body()) diff --git a/api/pkg/handlers/message_thread_handler.go b/api/pkg/handlers/message_thread_handler.go index 38282831..cd919254 100644 --- a/api/pkg/handlers/message_thread_handler.go +++ b/api/pkg/handlers/message_thread_handler.go @@ -40,7 +40,7 @@ func NewMessageThreadHandler( } // RegisterRoutes registers the routes for the MessageHandler -func (h *MessageThreadHandler) RegisterRoutes(router fiber.Router) { +func (h *MessageThreadHandler) RegisterRoutes(router fiber.Router, middlewares ...fiber.Handler) { router.Get("/message-threads", h.Index) router.Put("/message-threads/:messageThreadID", h.Update) router.Delete("/message-threads/:messageThreadID", h.Delete) diff --git a/api/pkg/handlers/phone_api_key_handler.go b/api/pkg/handlers/phone_api_key_handler.go index 227aa773..40b8aa1c 100644 --- a/api/pkg/handlers/phone_api_key_handler.go +++ b/api/pkg/handlers/phone_api_key_handler.go @@ -17,11 +17,10 @@ import ( // PhoneAPIKeyHandler handles phone API key http requests type PhoneAPIKeyHandler struct { handler - logger telemetry.Logger - tracer telemetry.Tracer - validator *validators.PhoneAPIKeyHandlerValidator - service *services.PhoneAPIKeyService - phoneService *services.PhoneService + logger telemetry.Logger + tracer telemetry.Tracer + validator *validators.PhoneAPIKeyHandlerValidator + service *services.PhoneAPIKeyService } // NewPhoneAPIKeyHandler creates a new PhoneAPIKeyHandler @@ -30,14 +29,12 @@ func NewPhoneAPIKeyHandler( tracer telemetry.Tracer, validator *validators.PhoneAPIKeyHandlerValidator, service *services.PhoneAPIKeyService, - phoneService *services.PhoneService, ) *PhoneAPIKeyHandler { return &PhoneAPIKeyHandler{ - logger: logger.WithService(fmt.Sprintf("%T", &PhoneAPIKeyHandler{})), - tracer: tracer, - validator: validator, - service: service, - phoneService: phoneService, + logger: logger.WithService(fmt.Sprintf("%T", &PhoneAPIKeyHandler{})), + tracer: tracer, + validator: validator, + service: service, } } diff --git a/api/pkg/handlers/phone_handler.go b/api/pkg/handlers/phone_handler.go index b64c9d0f..9e5cbe1c 100644 --- a/api/pkg/handlers/phone_handler.go +++ b/api/pkg/handlers/phone_handler.go @@ -38,10 +38,15 @@ func NewPhoneHandler( } // RegisterRoutes registers the routes for the PhoneHandler -func (h *PhoneHandler) RegisterRoutes(router fiber.Router) { - router.Get("/phones", h.Index) - router.Put("/phones", h.Upsert) - router.Delete("/phones/:phoneID", h.Delete) +func (h *PhoneHandler) RegisterRoutes(router fiber.Router, middlewares ...fiber.Handler) { + router.Get("/v1/phones", h.computeRoute(middlewares, h.Index)...) + router.Put("/v1/phones", h.computeRoute(middlewares, h.Upsert)...) + router.Delete("/v1/phones/:phoneID", h.computeRoute(middlewares, h.Delete)...) +} + +// RegisterPhoneAPIKeyRoutes registers the routes for the PhoneHandler +func (h *PhoneHandler) RegisterPhoneAPIKeyRoutes(router fiber.Router, middlewares ...fiber.Handler) { + router.Put("/v1/phones/fcm-token", h.computeRoute(middlewares, h.UpsertFCMToken)...) } // Index returns the phones of a user @@ -168,3 +173,46 @@ func (h *PhoneHandler) Delete(c *fiber.Ctx) error { return h.responseOK(c, "phone deleted successfully", nil) } + +// UpsertFCMToken upserts the FCM token of a phone +// @Summary Upserts the FCM token of a phone +// @Description Updates the FCM token of a phone. If the phone with this number does not exist, a new one will be created. Think of this method like an 'upsert' +// @Security ApiKeyAuth +// @Tags Phones +// @Accept json +// @Produce json +// @Param payload body requests.PhoneFCMToken true "Payload of new FCM token." +// @Success 200 {object} responses.PhoneResponse +// @Failure 400 {object} responses.BadRequest +// @Failure 401 {object} responses.Unauthorized +// @Failure 422 {object} responses.UnprocessableEntity +// @Failure 500 {object} responses.InternalServerError +// @Router /phones/fcm-token [put] +func (h *PhoneHandler) UpsertFCMToken(c *fiber.Ctx) error { + ctx, span := h.tracer.StartFromFiberCtx(c) + defer span.End() + + ctxLogger := h.tracer.CtxLogger(h.logger, span) + + var request requests.PhoneFCMToken + if err := c.BodyParser(&request); err != nil { + msg := fmt.Sprintf("cannot marshall params [%s] into %T", c.OriginalURL(), request) + ctxLogger.Warn(stacktrace.Propagate(err, msg)) + return h.responseBadRequest(c, err) + } + + if errors := h.validator.ValidateFCMToken(ctx, request.Sanitize()); len(errors) != 0 { + msg := fmt.Sprintf("validation errors [%s], while updating phones [%+#v]", spew.Sdump(errors), request) + ctxLogger.Warn(stacktrace.NewError(msg)) + return h.responseUnprocessableEntity(c, errors, "validation errors while updating phones") + } + + phone, err := h.service.UpsertFCMToken(ctx, request.ToPhoneFCMTokenParams(h.userFromContext(c), c.OriginalURL())) + if err != nil { + msg := fmt.Sprintf("cannot delete phones with params [%+#v]", request) + ctxLogger.Error(stacktrace.Propagate(err, msg)) + return h.responseInternalServerError(c) + } + + return h.responseOK(c, "FCM token updated successfully", phone) +} diff --git a/api/pkg/handlers/user_handler.go b/api/pkg/handlers/user_handler.go index daa0aaab..af77e718 100644 --- a/api/pkg/handlers/user_handler.go +++ b/api/pkg/handlers/user_handler.go @@ -38,14 +38,14 @@ func NewUserHandler( } // RegisterRoutes registers the routes for the MessageHandler -func (h *UserHandler) RegisterRoutes(router fiber.Router) { - router.Get("/users/me", h.Show) - router.Put("/users/me", h.Update) - router.Delete("/users/me", h.Delete) - router.Delete("/users/:userID/api-keys", h.DeleteAPIKey) - router.Put("/users/:userID/notifications", h.UpdateNotifications) - router.Get("/users/subscription-update-url", h.subscriptionUpdateURL) - router.Delete("/users/subscription", h.cancelSubscription) +func (h *UserHandler) RegisterRoutes(router fiber.Router, middlewares ...fiber.Handler) { + router.Get("/v1/users/me", h.computeRoute(middlewares, h.Show)...) + router.Put("/v1/users/me", h.computeRoute(middlewares, h.Update)...) + router.Delete("/v1/users/me", h.computeRoute(middlewares, h.Delete)...) + router.Delete("/v1/users/:userID/api-keys", h.computeRoute(middlewares, h.DeleteAPIKey)...) + router.Put("/v1/users/:userID/notifications", h.computeRoute(middlewares, h.UpdateNotifications)...) + router.Get("/v1/users/subscription-update-url", h.computeRoute(middlewares, h.subscriptionUpdateURL)...) + router.Delete("/v1/users/subscription", h.computeRoute(middlewares, h.cancelSubscription)...) } // Show returns an entities.User diff --git a/api/pkg/handlers/webhook_handler.go b/api/pkg/handlers/webhook_handler.go index 72cb407a..54df0f68 100644 --- a/api/pkg/handlers/webhook_handler.go +++ b/api/pkg/handlers/webhook_handler.go @@ -41,12 +41,11 @@ func NewWebhookHandler( } // RegisterRoutes registers the routes for the WebhookHandler -func (h *WebhookHandler) RegisterRoutes(app *fiber.App, middlewares ...fiber.Handler) { - router := app.Group("/v1/webhooks") - router.Get("/", h.computeRoute(middlewares, h.Index)...) - router.Post("/", h.computeRoute(middlewares, h.Store)...) - router.Put("/:webhookID", h.computeRoute(middlewares, h.Update)...) - router.Delete("/:webhookID", h.computeRoute(middlewares, h.Delete)...) +func (h *WebhookHandler) RegisterRoutes(router fiber.Router, middlewares ...fiber.Handler) { + router.Get("/v1/webhooks", h.computeRoute(middlewares, h.Index)...) + router.Post("/v1/webhooks", h.computeRoute(middlewares, h.Store)...) + router.Put("/v1/webhooks/:webhookID", h.computeRoute(middlewares, h.Update)...) + router.Delete("/v1/webhooks/:webhookID", h.computeRoute(middlewares, h.Delete)...) } // Index returns the webhooks of a user diff --git a/api/pkg/listeners/phone_api_key_listener.go b/api/pkg/listeners/phone_api_key_listener.go new file mode 100644 index 00000000..626063f0 --- /dev/null +++ b/api/pkg/listeners/phone_api_key_listener.go @@ -0,0 +1,104 @@ +package listeners + +import ( + "context" + "fmt" + + "github.com/NdoleStudio/httpsms/pkg/entities" + + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/davecgh/go-spew/spew" + "github.com/palantir/stacktrace" + + "github.com/NdoleStudio/httpsms/pkg/events" + "github.com/NdoleStudio/httpsms/pkg/services" + "github.com/NdoleStudio/httpsms/pkg/telemetry" +) + +// PhoneAPIKeyListener handles cloud events that alter the state of entities.PhoneAPIKey +type PhoneAPIKeyListener struct { + logger telemetry.Logger + tracer telemetry.Tracer + service *services.PhoneAPIKeyService +} + +// NewPhoneAPIKeyListener creates a new instance of PhoneAPIKeyListener +func NewPhoneAPIKeyListener( + logger telemetry.Logger, + tracer telemetry.Tracer, + service *services.PhoneAPIKeyService, +) (l *PhoneAPIKeyListener, routes map[string]events.EventListener) { + l = &PhoneAPIKeyListener{ + logger: logger.WithService(fmt.Sprintf("%T", l)), + tracer: tracer, + service: service, + } + + return l, map[string]events.EventListener{ + events.EventTypePhoneUpdated: l.onPhoneUpdated, + events.EventTypePhoneDeleted: l.onPhoneDeleted, + events.UserAccountDeleted: l.onUserAccountDeleted, + } +} + +// onPhoneUpdated handles the events.EventTypePhoneUpdated event +func (listener *PhoneAPIKeyListener) onPhoneUpdated(ctx context.Context, event cloudevents.Event) error { + ctx, span, ctxLogger := listener.tracer.StartWithLogger(ctx, listener.logger) + defer span.End() + + var payload events.PhoneUpdatedPayload + if err := event.DataAs(&payload); err != nil { + msg := fmt.Sprintf("cannot decode [%s] into [%T]", event.Data(), payload) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + if payload.PhoneAPIKeyID == nil { + ctxLogger.Info(fmt.Sprintf("phone API Key does not exist for [%s] event with ID [%s] and phone with ID [%s] for user [%S]", event.Type(), event.ID(), payload.PhoneID, payload.UserID)) + return nil + } + + if err := listener.service.AddPhone(ctx, payload.UserID, *payload.PhoneAPIKeyID, payload.PhoneID); err != nil { + msg := fmt.Sprintf("cannot store heartbeat monitor with params [%s] for event with ID [%s]", spew.Sdump(payload), event.ID()) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + return nil +} + +// onPhoneUpdated handles the events.EventTypePhoneUpdated event +func (listener *PhoneAPIKeyListener) onPhoneDeleted(ctx context.Context, event cloudevents.Event) error { + ctx, span, _ := listener.tracer.StartWithLogger(ctx, listener.logger) + defer span.End() + + var payload events.PhoneDeletedPayload + if err := event.DataAs(&payload); err != nil { + msg := fmt.Sprintf("cannot decode [%s] into [%T]", event.Data(), payload) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + if err := listener.service.RemovePhoneByID(ctx, payload.UserID, payload.PhoneID, payload.Owner); err != nil { + msg := fmt.Sprintf("cannot remove phone with ID [%s] from phone api key for [%s] event with ID [%s]", payload.PhoneID, event.Type(), event.ID()) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + return nil +} + +// onUserAccountDeleted handles the events.EventTypePhoneUpdated event +func (listener *PhoneAPIKeyListener) onUserAccountDeleted(ctx context.Context, event cloudevents.Event) error { + ctx, span, _ := listener.tracer.StartWithLogger(ctx, listener.logger) + defer span.End() + + var payload events.UserAccountDeletedPayload + if err := event.DataAs(&payload); err != nil { + msg := fmt.Sprintf("cannot decode [%s] into [%T]", event.Data(), payload) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + if err := listener.service.DeleteAllForUser(ctx, payload.UserID); err != nil { + msg := fmt.Sprintf("cannot delete all [%T] for user with ID [%s] for [%s] event with ID [%s]", entities.PhoneAPIKey{}, payload.UserID, event.Type(), event.ID()) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + return nil +} diff --git a/api/pkg/middlewares/api_key_auth_middleware.go b/api/pkg/middlewares/api_key_auth_middleware.go index cc797bff..17ac4335 100644 --- a/api/pkg/middlewares/api_key_auth_middleware.go +++ b/api/pkg/middlewares/api_key_auth_middleware.go @@ -2,6 +2,7 @@ package middlewares import ( "fmt" + "strings" "github.com/NdoleStudio/httpsms/pkg/repositories" "github.com/NdoleStudio/httpsms/pkg/telemetry" @@ -20,8 +21,8 @@ func APIKeyAuth(logger telemetry.Logger, tracer telemetry.Tracer, userRepository ctxLogger := tracer.CtxLogger(logger, span) apiKey := getAPIKeyFromRequest(c) - if len(apiKey) == 0 || apiKey == "undefined" { - span.AddEvent(fmt.Sprintf("the request header has no [%s] header", authHeaderAPIKey)) + if len(apiKey) == 0 || apiKey == "undefined" || strings.HasPrefix(apiKey, "pk_") { + span.AddEvent(fmt.Sprintf("the request header has no primary [%s] header", authHeaderAPIKey)) return c.Next() } diff --git a/api/pkg/middlewares/phone_api_key_auth_middleware.go b/api/pkg/middlewares/phone_api_key_auth_middleware.go new file mode 100644 index 00000000..dc19c3b8 --- /dev/null +++ b/api/pkg/middlewares/phone_api_key_auth_middleware.go @@ -0,0 +1,37 @@ +package middlewares + +import ( + "fmt" + "strings" + + "github.com/NdoleStudio/httpsms/pkg/repositories" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/gofiber/fiber/v2" + "github.com/palantir/stacktrace" +) + +// PhoneAPIKeyAuth authenticates a user from the X-API-Key header +func PhoneAPIKeyAuth(logger telemetry.Logger, tracer telemetry.Tracer, repository repositories.PhoneAPIKeyRepository) fiber.Handler { + logger = logger.WithService("middlewares.APIKeyAuth") + + return func(c *fiber.Ctx) error { + ctx, span, ctxLogger := tracer.StartFromFiberCtxWithLogger(c, logger, "middlewares.APIKeyAuth") + defer span.End() + + apiKey := c.Get(authHeaderAPIKey) + if len(apiKey) == 0 || apiKey == "undefined" || !strings.HasPrefix(apiKey, "pk_") { + span.AddEvent(fmt.Sprintf("the request header has no [%s] header for the phone key", authHeaderAPIKey)) + return c.Next() + } + + authUser, err := repository.LoadAuthContext(ctx, apiKey) + if err != nil { + ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("cannot load user with phone api key [%s]", apiKey))) + return c.Next() + } + + c.Locals(ContextKeyAuthUserID, authUser) + ctxLogger.Info(fmt.Sprintf("[%T] set successfully for user with ID [%s]", authUser, authUser.ID)) + return c.Next() + } +} diff --git a/api/pkg/repositories/gorm_message_repository.go b/api/pkg/repositories/gorm_message_repository.go index 955f1f0d..607af44e 100644 --- a/api/pkg/repositories/gorm_message_repository.go +++ b/api/pkg/repositories/gorm_message_repository.go @@ -223,18 +223,23 @@ func (repository *gormMessageRepository) Update(ctx context.Context, message *en } // GetOutstanding fetches messages that still to be sent to the phone -func (repository *gormMessageRepository) GetOutstanding(ctx context.Context, userID entities.UserID, messageID uuid.UUID) (*entities.Message, error) { +func (repository *gormMessageRepository) GetOutstanding(ctx context.Context, userID entities.UserID, messageID uuid.UUID, phoneNumbers []string) (*entities.Message, error) { ctx, span := repository.tracer.Start(ctx) defer span.End() message := new(entities.Message) err := crdbgorm.ExecuteTx(ctx, repository.db, nil, func(tx *gorm.DB) error { - return tx.WithContext(ctx).Model(message). + query := tx.WithContext(ctx).Model(message). Clauses(clause.Returning{}). Where("user_id = ?", userID). - Where("id = ?", messageID). - Where(repository.db.Where("status = ?", entities.MessageStatusScheduled).Or("status = ?", entities.MessageStatusPending).Or("status = ?", entities.MessageStatusExpired)). + Where("id = ?", messageID) + + if len(phoneNumbers) > 0 { + query = query.Where("owner IN ?", phoneNumbers) + } + + return query.Where(repository.db.Where("status = ?", entities.MessageStatusScheduled).Or("status = ?", entities.MessageStatusPending).Or("status = ?", entities.MessageStatusExpired)). Update("status", entities.MessageStatusSending).Error }, ) diff --git a/api/pkg/repositories/gorm_phone_api_key_repository.go b/api/pkg/repositories/gorm_phone_api_key_repository.go index 6feecd2a..86e4786b 100644 --- a/api/pkg/repositories/gorm_phone_api_key_repository.go +++ b/api/pkg/repositories/gorm_phone_api_key_repository.go @@ -6,6 +6,8 @@ import ( "fmt" "time" + "github.com/cockroachdb/cockroach-go/v2/crdb/crdbgorm" + "github.com/NdoleStudio/httpsms/pkg/entities" "github.com/NdoleStudio/httpsms/pkg/telemetry" "github.com/dgraph-io/ristretto" @@ -26,8 +28,8 @@ type gormPhoneAPIKeyRepository struct { func NewGormPhoneAPIKeyRepository( logger telemetry.Logger, tracer telemetry.Tracer, - cache *ristretto.Cache[string, entities.AuthContext], db *gorm.DB, + cache *ristretto.Cache[string, entities.AuthContext], ) PhoneAPIKeyRepository { return &gormPhoneAPIKeyRepository{ logger: logger.WithService(fmt.Sprintf("%T", &gormPhoneAPIKeyRepository{})), @@ -37,7 +39,29 @@ func NewGormPhoneAPIKeyRepository( } } -// Load an entities.Integration3CX based on the entities.UserID +func (repository *gormPhoneAPIKeyRepository) RemovePhoneByID(ctx context.Context, userID entities.UserID, phoneID uuid.UUID, phoneNumber string) error { + ctx, span := repository.tracer.Start(ctx) + defer span.End() + + query := ` +UPDATE ? +SET phone_ids = array_remove(phone_ids, ?), + phone_numbers = array_remove(phone_numbers, ?) +WHERE user_id = ? AND array_position(phone_ids, ?) IS NOT NULL; +` + err := repository.db.WithContext(ctx). + Raw(query, (entities.PhoneAPIKey{}).TableName(), phoneID, phoneNumber, userID, phoneID). + Error + if err != nil { + msg := fmt.Sprintf("cannot remove phone with ID [%s] and number [%s] for user with ID [%s] ", phoneID, phoneNumber, userID) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + repository.cache.Clear() + return nil +} + +// Load an entities.PhoneAPIKey based on the entities.UserID func (repository *gormPhoneAPIKeyRepository) Load(ctx context.Context, userID entities.UserID, phoneAPIKeyID uuid.UUID) (*entities.PhoneAPIKey, error) { ctx, span := repository.tracer.Start(ctx) defer span.End() @@ -138,27 +162,45 @@ func (repository *gormPhoneAPIKeyRepository) Delete(ctx context.Context, phoneAP return nil } -func (repository *gormPhoneAPIKeyRepository) AddPhone(ctx context.Context, authContext entities.AuthContext, phoneID uuid.UUID, phoneNumber string) error { +func (repository *gormPhoneAPIKeyRepository) AddPhone(ctx context.Context, phoneAPIKey *entities.PhoneAPIKey, phone *entities.Phone) error { ctx, span := repository.tracer.Start(ctx) defer span.End() - query := ` + err := crdbgorm.ExecuteTx(ctx, repository.db, nil, func(tx *gorm.DB) error { + query := ` +UPDATE ? +SET phone_ids = array_remove(phone_ids, ?), + phone_numbers = array_remove(phone_numbers, ?) +WHERE user_id = ?; +` + err := tx.WithContext(ctx). + Raw(query, phoneAPIKey.TableName(), phone.ID, phone.PhoneNumber, phone.UserID). + Error + if err != nil { + msg := fmt.Sprintf("cannot remove phone with ID [%s] from API Key with ID [%s] for user with ID [%s]", phone.ID, phoneAPIKey.ID, phone.UserID) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + query = ` UPDATE ? SET phone_ids = array_append(phone_ids, ?), phone_numbers = array_append(phone_numbers, ?) WHERE array_position(phone_ids, ?) IS NULL AND id = ?; ` - - err := repository.db.WithContext(ctx). - Raw(query, (entities.PhoneAPIKey{}).TableName(), phoneID, phoneNumber, phoneID, *authContext.PhoneAPIKeyID). - Error + err = repository.db.WithContext(ctx). + Raw(query, phoneAPIKey.TableName(), phone.ID, phone.PhoneNumber, phoneAPIKey.ID). + Error + if err != nil { + msg := fmt.Sprintf("cannot add [%T] with ID [%s] from API Key with ID [%s] for user with ID [%s]", phone, phone.ID, phoneAPIKey.ID, phone.UserID) + return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + return nil + }) if err != nil { - msg := fmt.Sprintf("cannot add phone with ID [%s] to phone API key with ID [%s]", phoneID, *authContext.PhoneAPIKeyID) + msg := fmt.Sprintf("cannot add [%T] with ID [%s] from API Key with ID [%s] for user with ID [%s]", phone, phone.ID, phoneAPIKey.ID, phone.UserID) return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } - repository.cache.Clear() - return nil } @@ -173,7 +215,7 @@ SET phone_ids = array_remove(phone_ids, ?), WHERE id = ?; ` err := repository.db.WithContext(ctx). - Raw(query, (entities.PhoneAPIKey{}).TableName(), phone.ID, phone.PhoneNumber, phoneAPIKey.ID). + Raw(query, phoneAPIKey.TableName(), phone.ID, phone.PhoneNumber, phoneAPIKey.ID). Error if err != nil { msg := fmt.Sprintf("cannot remove phone with ID [%s] from phone API key with ID [%s]", phone.ID, phoneAPIKey.ID) diff --git a/api/pkg/repositories/message_repository.go b/api/pkg/repositories/message_repository.go index 3f742cc6..3ad70015 100644 --- a/api/pkg/repositories/message_repository.go +++ b/api/pkg/repositories/message_repository.go @@ -28,7 +28,7 @@ type MessageRepository interface { Search(ctx context.Context, userID entities.UserID, owners []string, types []entities.MessageType, statuses []entities.MessageStatus, params IndexParams) ([]*entities.Message, error) // GetOutstanding fetches an entities.Message which is outstanding - GetOutstanding(ctx context.Context, userID entities.UserID, messageID uuid.UUID) (*entities.Message, error) + GetOutstanding(ctx context.Context, userID entities.UserID, messageID uuid.UUID, phoneNumbers []string) (*entities.Message, error) // Delete an entities.Message by ID Delete(ctx context.Context, userID entities.UserID, messageID uuid.UUID) error diff --git a/api/pkg/repositories/phone_api_key_repository.go b/api/pkg/repositories/phone_api_key_repository.go index 52828980..8894e4ac 100644 --- a/api/pkg/repositories/phone_api_key_repository.go +++ b/api/pkg/repositories/phone_api_key_repository.go @@ -26,11 +26,14 @@ type PhoneAPIKeyRepository interface { Delete(ctx context.Context, phoneAPIKey *entities.PhoneAPIKey) error // AddPhone adds an entities.Phone to an entities.PhoneAPIKey - AddPhone(ctx context.Context, authContext entities.AuthContext, phoneID uuid.UUID, phoneNumber string) error + AddPhone(ctx context.Context, phoneAPIKey *entities.PhoneAPIKey, phone *entities.Phone) error // RemovePhone removes an entities.Phone to an entities.PhoneAPIKey RemovePhone(ctx context.Context, phoneAPIKey *entities.PhoneAPIKey, phone *entities.Phone) error // DeleteAllForUser deletes all entities.PhoneAPIKey for a user DeleteAllForUser(ctx context.Context, userID entities.UserID) error + + // RemovePhoneByID removes a phone by ID and phone number + RemovePhoneByID(ctx context.Context, userID entities.UserID, phoneID uuid.UUID, phoneNumber string) error } diff --git a/api/pkg/requests/message_outstanding_request.go b/api/pkg/requests/message_outstanding_request.go index a1a768eb..3d3209e9 100644 --- a/api/pkg/requests/message_outstanding_request.go +++ b/api/pkg/requests/message_outstanding_request.go @@ -22,11 +22,12 @@ func (input *MessageOutstanding) Sanitize() MessageOutstanding { } // ToGetOutstandingParams converts MessageOutstanding into services.MessageGetOutstandingParams -func (input *MessageOutstanding) ToGetOutstandingParams(source string, userID entities.UserID, timestamp time.Time) services.MessageGetOutstandingParams { +func (input *MessageOutstanding) ToGetOutstandingParams(source string, authCtx entities.AuthContext, timestamp time.Time) services.MessageGetOutstandingParams { return services.MessageGetOutstandingParams{ - Source: source, - UserID: userID, - MessageID: uuid.MustParse(input.MessageID), - Timestamp: timestamp, + Source: source, + PhoneNumbers: authCtx.PhoneNumbers, + UserID: authCtx.ID, + MessageID: uuid.MustParse(input.MessageID), + Timestamp: timestamp, } } diff --git a/api/pkg/requests/phone_fcm_token_request.go b/api/pkg/requests/phone_fcm_token_request.go new file mode 100644 index 00000000..dde935b5 --- /dev/null +++ b/api/pkg/requests/phone_fcm_token_request.go @@ -0,0 +1,40 @@ +package requests + +import ( + "strings" + + "github.com/nyaruka/phonenumbers" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/NdoleStudio/httpsms/pkg/services" +) + +// PhoneFCMToken is the payload for updating the FCM token of a phone +type PhoneFCMToken struct { + request + PhoneNumber string `json:"phone_number" example:"[+18005550199]"` + FcmToken string `json:"fcm_token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzd....."` + // SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot + SIM string `json:"sim" example:"SIM1"` +} + +// Sanitize sets defaults to MessageOutstanding +func (input *PhoneFCMToken) Sanitize() PhoneFCMToken { + input.FcmToken = strings.TrimSpace(input.FcmToken) + input.PhoneNumber = input.sanitizeAddress(input.PhoneNumber) + input.SIM = input.sanitizeSIM(input.SIM) + return *input +} + +// ToPhoneFCMTokenParams converts PhoneFCMToken to services.PhoneFCMTokenParams +func (input *PhoneFCMToken) ToPhoneFCMTokenParams(user entities.AuthContext, source string) *services.PhoneFCMTokenParams { + phone, _ := phonenumbers.Parse(input.PhoneNumber, phonenumbers.UNKNOWN_REGION) + return &services.PhoneFCMTokenParams{ + Source: source, + PhoneNumber: phone, + PhoneAPIKeyID: user.PhoneAPIKeyID, + UserID: user.ID, + FcmToken: &input.FcmToken, + SIM: entities.SIM(input.SIM), + } +} diff --git a/api/pkg/services/message_service.go b/api/pkg/services/message_service.go index c972709e..890908f6 100644 --- a/api/pkg/services/message_service.go +++ b/api/pkg/services/message_service.go @@ -49,10 +49,11 @@ func NewMessageService( // MessageGetOutstandingParams parameters for sending a new message type MessageGetOutstandingParams struct { - Source string - UserID entities.UserID - Timestamp time.Time - MessageID uuid.UUID + Source string + UserID entities.UserID + PhoneNumbers []string + Timestamp time.Time + MessageID uuid.UUID } // GetOutstanding fetches messages that still to be sent to the phone @@ -62,7 +63,7 @@ func (service *MessageService) GetOutstanding(ctx context.Context, params Messag ctxLogger := service.tracer.CtxLogger(service.logger, span) - message, err := service.repository.GetOutstanding(ctx, params.UserID, params.MessageID) + message, err := service.repository.GetOutstanding(ctx, params.UserID, params.MessageID, params.PhoneNumbers) if err != nil { msg := fmt.Sprintf("could not fetch outstanding messages with params [%s]", spew.Sdump(params)) return nil, service.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, stacktrace.GetCode(err), msg)) diff --git a/api/pkg/services/phone_api_key_service.go b/api/pkg/services/phone_api_key_service.go index fd8f2f5e..b3460478 100644 --- a/api/pkg/services/phone_api_key_service.go +++ b/api/pkg/services/phone_api_key_service.go @@ -40,7 +40,7 @@ func NewPhoneAPIKeyService( // Create a new entities.PhoneAPIKey func (service *PhoneAPIKeyService) Create(ctx context.Context, authContext entities.AuthContext, name string) (*entities.PhoneAPIKey, error) { - ctx, span := service.tracer.Start(ctx) + ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() apiKey, err := service.generateAPIKey(64) @@ -65,12 +65,13 @@ func (service *PhoneAPIKeyService) Create(ctx context.Context, authContext entit return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } + ctxLogger.Info(fmt.Sprintf("created [%T] with ID [%s] for user ID [%s]", phoneAPIKey, phoneAPIKey.ID, authContext.ID)) return phoneAPIKey, nil } // Delete an entities.PhoneAPIKey func (service *PhoneAPIKeyService) Delete(ctx context.Context, userID entities.UserID, phoneAPIKeyID uuid.UUID) error { - ctx, span := service.tracer.Start(ctx) + ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() phoneAPIKey, err := service.repository.Load(ctx, userID, phoneAPIKeyID) @@ -84,12 +85,13 @@ func (service *PhoneAPIKeyService) Delete(ctx context.Context, userID entities.U return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } + ctxLogger.Info(fmt.Sprintf("deleted [%T] with ID [%s] for user ID [%s]", phoneAPIKey, phoneAPIKey.ID, userID)) return nil } // RemovePhone removes the phone from the phone API key func (service *PhoneAPIKeyService) RemovePhone(ctx context.Context, userID entities.UserID, phoneAPIKeyID uuid.UUID, phoneID uuid.UUID) error { - ctx, span := service.tracer.Start(ctx) + ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() phone, err := service.phoneRepository.LoadByID(ctx, userID, phoneID) @@ -109,6 +111,61 @@ func (service *PhoneAPIKeyService) RemovePhone(ctx context.Context, userID entit return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } + ctxLogger.Info(fmt.Sprintf("removed [%T] with ID [%s] from [%T] with ID [%s] for user ID [%s]", phone, phoneID, phoneAPIKey, phoneAPIKeyID, userID)) + return nil +} + +// RemovePhoneByID removes the phone from the phone API key by phone number and phoneID +func (service *PhoneAPIKeyService) RemovePhoneByID(ctx context.Context, userID entities.UserID, phoneID uuid.UUID, phoneNumber string) error { + ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) + defer span.End() + + if err := service.repository.RemovePhoneByID(ctx, userID, phoneID, phoneNumber); err != nil { + msg := fmt.Sprintf("cannot remove [%T] with ID [%s] and number [%s] for user [%s]", &entities.Phone{}, phoneID, phoneNumber, userID.String()) + return stacktrace.Propagate(err, msg) + } + + ctxLogger.Info(fmt.Sprintf("removed phone with ID [%s] from [%T] for user ID [%s]", phoneID, &entities.PhoneAPIKey{}, userID)) + return nil +} + +// DeleteAllForUser removes all entities.PhoneAPIKey for a user +func (service *PhoneAPIKeyService) DeleteAllForUser(ctx context.Context, userID entities.UserID) error { + ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) + defer span.End() + + if err := service.repository.DeleteAllForUser(ctx, userID); err != nil { + msg := fmt.Sprintf("cannot delete all [%T] for user ID [%s]", &entities.PhoneAPIKey{}, userID) + return stacktrace.Propagate(err, msg) + } + + ctxLogger.Info(fmt.Sprintf("deleted all [%T] for user ID [%s]", &entities.PhoneAPIKey{}, userID)) + return nil +} + +// AddPhone adds a phone to the phone API key +func (service *PhoneAPIKeyService) AddPhone(ctx context.Context, userID entities.UserID, phoneAPIKeyID uuid.UUID, phoneID uuid.UUID) error { + ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) + defer span.End() + + phone, err := service.phoneRepository.LoadByID(ctx, userID, phoneID) + if err != nil { + msg := fmt.Sprintf("cannot load [%T] with ID [%s] for user [%s]", &entities.Phone{}, phoneID, userID.String()) + return stacktrace.Propagate(err, msg) + } + + phoneAPIKey, err := service.repository.Load(ctx, userID, phoneAPIKeyID) + if err != nil { + msg := fmt.Sprintf("cannot load [%T] with ID [%s] for user [%s]", &entities.PhoneAPIKey{}, phoneAPIKeyID, userID.String()) + return stacktrace.Propagate(err, msg) + } + + if err = service.repository.AddPhone(ctx, phoneAPIKey, phone); err != nil { + msg := fmt.Sprintf("cannot add [%T] with ID [%s] to phone API key with ID [%s] for user [%s]", phone, phone.ID, phoneAPIKey.ID, userID) + return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + ctxLogger.Info(fmt.Sprintf("added [%T] with ID [%s] to [%T] with ID [%s] for user ID [%s]", phone, phone.ID, phoneAPIKey, phoneAPIKeyID, userID)) return nil } diff --git a/api/pkg/services/phone_service.go b/api/pkg/services/phone_service.go index e4960ff6..df8e2104 100644 --- a/api/pkg/services/phone_service.go +++ b/api/pkg/services/phone_service.go @@ -104,7 +104,14 @@ func (service *PhoneService) Upsert(ctx context.Context, params *PhoneUpsertPara phone, err := service.repository.Load(ctx, params.UserID, phonenumbers.Format(params.PhoneNumber, phonenumbers.E164)) if stacktrace.GetCode(err) == repositories.ErrCodeNotFound { - return service.createPhone(ctx, params) + return service.createPhone(ctx, &PhoneFCMTokenParams{ + Source: params.Source, + PhoneNumber: params.PhoneNumber, + PhoneAPIKeyID: nil, + UserID: params.UserID, + FcmToken: params.FcmToken, + SIM: params.SIM, + }) } if err != nil { @@ -118,19 +125,27 @@ func (service *PhoneService) Upsert(ctx context.Context, params *PhoneUpsertPara } ctxLogger.Info(fmt.Sprintf("phone updated with id [%s] in the phone repository for user [%s]", phone.ID, phone.UserID)) - return phone, service.dispatchPhoneUpdatedEvent(ctx, params.Source, phone) + return phone, service.dispatchPhoneUpdatedEvent(ctx, phone, &PhoneFCMTokenParams{ + Source: params.Source, + PhoneNumber: params.PhoneNumber, + PhoneAPIKeyID: nil, + UserID: params.UserID, + FcmToken: params.FcmToken, + SIM: params.SIM, + }) } -func (service *PhoneService) dispatchPhoneUpdatedEvent(ctx context.Context, source string, phone *entities.Phone) error { +func (service *PhoneService) dispatchPhoneUpdatedEvent(ctx context.Context, phone *entities.Phone, input *PhoneFCMTokenParams) error { ctx, span := service.tracer.Start(ctx) defer span.End() - event, err := service.createPhoneUpdatedEvent(source, events.PhoneUpdatedPayload{ - PhoneID: phone.ID, - UserID: phone.UserID, - Timestamp: phone.UpdatedAt, - Owner: phone.PhoneNumber, - SIM: phone.SIM, + event, err := service.createPhoneUpdatedEvent(input.Source, events.PhoneUpdatedPayload{ + PhoneID: phone.ID, + UserID: phone.UserID, + Timestamp: phone.UpdatedAt, + PhoneAPIKeyID: input.PhoneAPIKeyID, + Owner: phone.PhoneNumber, + SIM: phone.SIM, }) if err != nil { msg := fmt.Sprintf("cannot create event when phone [%s] is updated for user [%s]", phone.ID, phone.UserID) @@ -184,7 +199,44 @@ func (service *PhoneService) Delete(ctx context.Context, source string, userID e return nil } -func (service *PhoneService) createPhone(ctx context.Context, params *PhoneUpsertParams) (*entities.Phone, error) { +// PhoneFCMTokenParams are parameters for upserting an entities.Phone +type PhoneFCMTokenParams struct { + Source string + PhoneNumber *phonenumbers.PhoneNumber + PhoneAPIKeyID *uuid.UUID + UserID entities.UserID + FcmToken *string + SIM entities.SIM +} + +// UpsertFCMToken the FCM token for an entities.Phone +func (service *PhoneService) UpsertFCMToken(ctx context.Context, params *PhoneFCMTokenParams) (*entities.Phone, error) { + ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) + defer span.End() + + phone, err := service.repository.Load(ctx, params.UserID, phonenumbers.Format(params.PhoneNumber, phonenumbers.E164)) + if stacktrace.GetCode(err) == repositories.ErrCodeNotFound { + return service.createPhone(ctx, params) + } + + if err != nil { + msg := fmt.Sprintf("cannot upsert FCM token for user with id [%s] and number [%s]", params.UserID, params.PhoneNumber) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + phone.FcmToken = params.FcmToken + phone.SIM = params.SIM + + if err = service.repository.Save(ctx, phone); err != nil { + msg := fmt.Sprintf("cannot update phone with id [%s] and number [%s]", phone.ID, phone.PhoneNumber) + return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + ctxLogger.Info(fmt.Sprintf("phone updated with id [%s] in the phone repository for user [%s]", phone.ID, phone.UserID)) + return phone, service.dispatchPhoneUpdatedEvent(ctx, phone, params) +} + +func (service *PhoneService) createPhone(ctx context.Context, params *PhoneFCMTokenParams) (*entities.Phone, error) { ctx, span, ctxLogger := service.tracer.StartWithLogger(ctx, service.logger) defer span.End() @@ -210,7 +262,7 @@ func (service *PhoneService) createPhone(ctx context.Context, params *PhoneUpser } ctxLogger.Info(fmt.Sprintf("phone updated with id [%s] in the phone repository for user [%s]", phone.ID, phone.UserID)) - return phone, service.dispatchPhoneUpdatedEvent(ctx, params.Source, phone) + return phone, service.dispatchPhoneUpdatedEvent(ctx, phone, params) } func (service *PhoneService) createPhoneUpdatedEvent(source string, payload events.PhoneUpdatedPayload) (cloudevents.Event, error) { diff --git a/api/pkg/validators/phone_api_key_handler_validator.go b/api/pkg/validators/phone_api_key_handler_validator.go index 85b7b4f1..47fb870e 100644 --- a/api/pkg/validators/phone_api_key_handler_validator.go +++ b/api/pkg/validators/phone_api_key_handler_validator.go @@ -21,8 +21,8 @@ type PhoneAPIKeyHandlerValidator struct { func NewPhoneAPIKeyHandlerValidator( logger telemetry.Logger, tracer telemetry.Tracer, -) (v *PhoneHandlerValidator) { - return &PhoneHandlerValidator{ +) (v *PhoneAPIKeyHandlerValidator) { + return &PhoneAPIKeyHandlerValidator{ logger: logger.WithService(fmt.Sprintf("%T", v)), tracer: tracer, } diff --git a/api/pkg/validators/phone_handler_validator.go b/api/pkg/validators/phone_handler_validator.go index 1beba13d..2369214e 100644 --- a/api/pkg/validators/phone_handler_validator.go +++ b/api/pkg/validators/phone_handler_validator.go @@ -99,6 +99,29 @@ func (validator *PhoneHandlerValidator) ValidateUpsert(_ context.Context, reques return result } +// ValidateFCMToken validates requests.PhoneFCMToken +func (validator *PhoneHandlerValidator) ValidateFCMToken(_ context.Context, request requests.PhoneFCMToken) url.Values { + v := govalidator.New(govalidator.Options{ + Data: &request, + Rules: govalidator.MapData{ + "phone_number": []string{ + "required", + phoneNumberRule, + }, + "fcm_token": []string{ + "min:0", + "max:1000", + }, + "sim": []string{ + "required", + "in:" + strings.Join([]string{entities.SIM1.String(), entities.SIM2.String()}, ","), + }, + }, + }) + + return v.ValidateStruct() +} + // ValidateDelete ValidateUpsert validates requests.PhoneDelete func (validator *PhoneHandlerValidator) ValidateDelete(_ context.Context, request requests.PhoneDelete) url.Values { v := govalidator.New(govalidator.Options{ From e3cac34e03f2b4c218ee3630d26f5f65e5d566e2 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Tue, 22 Apr 2025 17:08:33 +0200 Subject: [PATCH 006/336] Add pusher websocket listener for phone updated events --- android/build.gradle | 4 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- api/.env.docker | 7 +++ api/go.mod | 9 +-- api/go.sum | 23 +++++--- api/pkg/di/container.go | 35 ++++++++++++ api/pkg/handlers/phone_api_key_handler.go | 4 +- api/pkg/listeners/websocket_listener.go | 56 +++++++++++++++++++ 8 files changed, 123 insertions(+), 17 deletions(-) create mode 100644 api/pkg/listeners/websocket_listener.go diff --git a/android/build.gradle b/android/build.gradle index e234c71a..bb5e2f77 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -17,8 +17,8 @@ buildscript { } plugins { - id 'com.android.application' version '8.7.3' apply false - id 'com.android.library' version '8.7.3' apply false + id 'com.android.application' version '8.8.0' apply false + id 'com.android.library' version '8.8.0' apply false id 'org.jetbrains.kotlin.android' version '1.6.21' apply false } diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index c9d1c8d8..e0e32b56 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Thu Jun 23 15:32:32 EEST 2022 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME diff --git a/api/.env.docker b/api/.env.docker index 2a54701a..9dc43fdb 100644 --- a/api/.env.docker +++ b/api/.env.docker @@ -51,3 +51,10 @@ REDIS_URL=redis://@redis:6379 # [optional] If you would like to use uptrace.dev for distributed tracing, you can set the DSN here. # This is optional and you can leave it empty if you don't want to use uptrace UPTRACE_DSN= + + +# [optional] Websocket configuration for https://pusher.com if you will like to frontend to update in real time +PUSHER_APP_ID= +PUSHER_KEY= +PUSHER_SECRET= +PUSHER_CLUSTER= diff --git a/api/go.mod b/api/go.mod index 43d90927..47b7497d 100644 --- a/api/go.mod +++ b/api/go.mod @@ -35,6 +35,7 @@ require ( github.com/palantir/stacktrace v0.0.0-20161112013806-78658fd2d177 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pkg/errors v0.9.1 + github.com/pusher/pusher-http-go/v5 v5.1.1 github.com/redis/go-redis/extra/redisotel/v9 v9.7.1 github.com/redis/go-redis/v9 v9.7.3 github.com/rs/zerolog v1.34.0 @@ -151,13 +152,13 @@ require ( go.opentelemetry.io/proto/otlp v1.5.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect - golang.org/x/crypto v0.36.0 // indirect + golang.org/x/crypto v0.37.0 // indirect golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect golang.org/x/net v0.38.0 // indirect golang.org/x/oauth2 v0.27.0 // indirect - golang.org/x/sync v0.12.0 // indirect - golang.org/x/sys v0.31.0 // indirect - golang.org/x/text v0.23.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/text v0.24.0 // indirect golang.org/x/time v0.10.0 // indirect golang.org/x/tools v0.31.0 // indirect google.golang.org/appengine v1.6.8 // indirect diff --git a/api/go.sum b/api/go.sum index a5501cf9..faecefdb 100644 --- a/api/go.sum +++ b/api/go.sum @@ -246,6 +246,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgm github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pusher/pusher-http-go/v5 v5.1.1 h1:ZLUGdLA8yXMvByafIkS47nvuXOHrYmlh4bsQvuZnYVQ= +github.com/pusher/pusher-http-go/v5 v5.1.1/go.mod h1:Ibji4SGoUDtOy7CVRhCiEpgy+n5Xv6hSL/QqYOhmWW8= github.com/redis/go-redis/extra/rediscmd/v9 v9.7.1 h1:+o7rrBoj54t8fqQSmnwRLdLzp5rps7bW4xiYZp2MBjs= github.com/redis/go-redis/extra/rediscmd/v9 v9.7.1/go.mod h1:bWIjbxmrAk9eKGg9LSko3oQefoYGyWV4xzNS55PgL60= github.com/redis/go-redis/extra/redisotel/v9 v9.7.1 h1:LJF39lvUagUpKfL2/gZIp5vHv3AwXt9zOZ/Xual/CzI= @@ -363,11 +365,12 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20181029175232-7e6ffbd03851/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= -golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= -golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= @@ -378,6 +381,7 @@ golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -393,10 +397,11 @@ golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190225065934-cc5685c2db12/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -409,8 +414,8 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -425,8 +430,8 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4= golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -457,6 +462,8 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/stretchr/testify.v1 v1.2.2 h1:yhQC6Uy5CqibAIlk1wlusa/MJ3iAN49/BsR/dCCKz3M= +gopkg.in/stretchr/testify.v1 v1.2.2/go.mod h1:QI5V/q6UbPmuhtm10CaFZxED9NreB8PnFYN9JcR6TxU= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index 51a0b303..cd21e074 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -9,6 +9,8 @@ import ( "strconv" "time" + "github.com/pusher/pusher-http-go/v5" + "github.com/NdoleStudio/httpsms/docs" otelMetric "go.opentelemetry.io/otel/metric" @@ -149,6 +151,7 @@ func NewContainer(projectID string, version string) (container *Container) { container.RegisterPhoneAPIKeyListeners() container.RegisterMarketingListeners() + container.RegisterWebsocketListeners() // this has to be last since it registers the /* route container.RegisterSwaggerRoutes() @@ -1142,6 +1145,18 @@ func (container *Container) LemonsqueezyClient() (client *lemonsqueezy.Client) { ) } +// PusherClient creates a new instance of pusher.Client +func (container *Container) PusherClient() (client *pusher.Client) { + container.logger.Debug(fmt.Sprintf("creating %T", client)) + return &pusher.Client{ + AppID: os.Getenv("PUSHER_APP_ID"), + Key: os.Getenv("PUSHER_KEY"), + Secret: os.Getenv("PUSHER_SECRET"), + Cluster: os.Getenv("PUSHER_CLUSTER"), + Secure: true, + } +} + // DiscordClient creates a new instance of discord.Client func (container *Container) DiscordClient() (client *discord.Client) { container.logger.Debug(fmt.Sprintf("creating %T", client)) @@ -1316,6 +1331,26 @@ func (container *Container) RegisterPhoneAPIKeyListeners() { } } +// RegisterWebsocketListeners registers event listeners for listeners.WebsocketListener +func (container *Container) RegisterWebsocketListeners() { + container.logger.Debug(fmt.Sprintf("registering listeners for %T", listeners.WebsocketListener{})) + + if os.Getenv("PUSHER_SECRET") == "" { + container.logger.Warn(stacktrace.NewError("skipping websocket listeners because the PUSHER_SECRET env variable is not set")) + return + } + + _, routes := listeners.NewWebsocketListener( + container.Logger(), + container.Tracer(), + container.PusherClient(), + ) + + for event, handler := range routes { + container.EventDispatcher().Subscribe(event, handler) + } +} + // RegisterWebhookListeners registers event listeners for listeners.WebhookListener func (container *Container) RegisterWebhookListeners() { container.logger.Debug(fmt.Sprintf("registering listeners for %T", listeners.WebhookListener{})) diff --git a/api/pkg/handlers/phone_api_key_handler.go b/api/pkg/handlers/phone_api_key_handler.go index 40b8aa1c..c52033af 100644 --- a/api/pkg/handlers/phone_api_key_handler.go +++ b/api/pkg/handlers/phone_api_key_handler.go @@ -103,7 +103,7 @@ func (h *PhoneAPIKeyHandler) Store(c *fiber.Ctx) error { // @Failure 404 {object} responses.NotFound // @Failure 422 {object} responses.UnprocessableEntity // @Failure 500 {object} responses.InternalServerError -// @Router /messages/{phoneAPIKeyID} [delete] +// @Router /api-keys/{phoneAPIKeyID} [delete] func (h *PhoneAPIKeyHandler) Delete(c *fiber.Ctx) error { ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) defer span.End() @@ -144,7 +144,7 @@ func (h *PhoneAPIKeyHandler) Delete(c *fiber.Ctx) error { // @Failure 404 {object} responses.NotFound // @Failure 422 {object} responses.UnprocessableEntity // @Failure 500 {object} responses.InternalServerError -// @Router /messages/{phoneAPIKeyID}/phones/{phoneID} [delete] +// @Router /api-keys/{phoneAPIKeyID}/phones/{phoneID} [delete] func (h *PhoneAPIKeyHandler) DeletePhone(c *fiber.Ctx) error { ctx, span, ctxLogger := h.tracer.StartFromFiberCtxWithLogger(c, h.logger) defer span.End() diff --git a/api/pkg/listeners/websocket_listener.go b/api/pkg/listeners/websocket_listener.go new file mode 100644 index 00000000..e7682969 --- /dev/null +++ b/api/pkg/listeners/websocket_listener.go @@ -0,0 +1,56 @@ +package listeners + +import ( + "context" + "fmt" + + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/palantir/stacktrace" + + "github.com/NdoleStudio/httpsms/pkg/events" + "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/pusher/pusher-http-go/v5" +) + +// WebsocketListener handles cloud events that send a websocket event to the frontend +type WebsocketListener struct { + logger telemetry.Logger + tracer telemetry.Tracer + client *pusher.Client +} + +// NewWebsocketListener creates a new instance of WebsocketListener +func NewWebsocketListener( + logger telemetry.Logger, + tracer telemetry.Tracer, + client *pusher.Client, +) (l *WebsocketListener, routes map[string]events.EventListener) { + l = &WebsocketListener{ + logger: logger.WithService(fmt.Sprintf("%T", l)), + tracer: tracer, + client: client, + } + + return l, map[string]events.EventListener{ + events.EventTypePhoneUpdated: l.onPhoneUpdated, + } +} + +// onPhoneUpdated handles the events.EventTypePhoneUpdated event +func (listener *WebsocketListener) onPhoneUpdated(ctx context.Context, event cloudevents.Event) error { + ctx, span, _ := listener.tracer.StartWithLogger(ctx, listener.logger) + defer span.End() + + var payload events.PhoneUpdatedPayload + if err := event.DataAs(&payload); err != nil { + msg := fmt.Sprintf("cannot decode [%s] into [%T]", event.Data(), payload) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + if err := listener.client.Trigger(payload.UserID.String(), event.Type(), event); err != nil { + msg := fmt.Sprintf("cannot trigger websocket [%s] event with ID [%s] for user with ID [%s]", event.Type(), event.ID(), payload.UserID) + return listener.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) + } + + return nil +} From 909dc4a1ec4cd30b2f7b305d5f9f09d229e08504 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Tue, 22 Apr 2025 17:54:31 +0200 Subject: [PATCH 007/336] Add pusher.js polling for phone updated events --- api/pkg/handlers/billing_handler.go | 4 +- api/pkg/handlers/heartbeat_handler.go | 4 +- api/pkg/handlers/message_thread_handler.go | 6 +- web/.env.production | 3 + web/layouts/default.vue | 39 +- web/nuxt.config.js | 2 + web/package.json | 1 + web/pnpm-lock.yaml | 12585 ++++++++++++++----- web/store/index.ts | 16 + 9 files changed, 9393 insertions(+), 3267 deletions(-) diff --git a/api/pkg/handlers/billing_handler.go b/api/pkg/handlers/billing_handler.go index 4c0da0c2..bcdb5248 100644 --- a/api/pkg/handlers/billing_handler.go +++ b/api/pkg/handlers/billing_handler.go @@ -38,8 +38,8 @@ func NewBillingHandler( // RegisterRoutes registers the routes for the MessageHandler func (h *BillingHandler) RegisterRoutes(router fiber.Router, middlewares ...fiber.Handler) { - router.Get("/billing/usage-history", h.computeRoute(middlewares, h.UsageHistory)...) - router.Get("/billing/usage", h.computeRoute(middlewares, h.Usage)...) + router.Get("/v1/billing/usage-history", h.computeRoute(middlewares, h.UsageHistory)...) + router.Get("/v1/billing/usage", h.computeRoute(middlewares, h.Usage)...) } // UsageHistory returns the usage history of a user diff --git a/api/pkg/handlers/heartbeat_handler.go b/api/pkg/handlers/heartbeat_handler.go index e7fb7114..f84cc0f9 100644 --- a/api/pkg/handlers/heartbeat_handler.go +++ b/api/pkg/handlers/heartbeat_handler.go @@ -41,12 +41,12 @@ func NewHeartbeatHandler( // RegisterRoutes registers the routes for the HeartbeatHandler func (h *HeartbeatHandler) RegisterRoutes(router fiber.Router, middlewares ...fiber.Handler) { - router.Get("/heartbeats", h.computeRoute(middlewares, h.Index)...) + router.Get("/v1/heartbeats", h.computeRoute(middlewares, h.Index)...) } // RegisterPhoneAPIKeyRoutes registers the routes for the HeartbeatHandler func (h *HeartbeatHandler) RegisterPhoneAPIKeyRoutes(router fiber.Router, middlewares ...fiber.Handler) { - router.Post("/heartbeats", h.computeRoute(middlewares, h.Store)...) + router.Post("/v1/heartbeats", h.computeRoute(middlewares, h.Store)...) } // Index returns the heartbeats of a phone number diff --git a/api/pkg/handlers/message_thread_handler.go b/api/pkg/handlers/message_thread_handler.go index cd919254..fc83d47c 100644 --- a/api/pkg/handlers/message_thread_handler.go +++ b/api/pkg/handlers/message_thread_handler.go @@ -41,9 +41,9 @@ func NewMessageThreadHandler( // RegisterRoutes registers the routes for the MessageHandler func (h *MessageThreadHandler) RegisterRoutes(router fiber.Router, middlewares ...fiber.Handler) { - router.Get("/message-threads", h.Index) - router.Put("/message-threads/:messageThreadID", h.Update) - router.Delete("/message-threads/:messageThreadID", h.Delete) + router.Get("/v1/message-threads", h.computeRoute(middlewares, h.Index)...) + router.Put("/v1/message-threads/:messageThreadID", h.computeRoute(middlewares, h.Update)...) + router.Delete("/v1/message-threads/:messageThreadID", h.computeRoute(middlewares, h.Delete)...) } // Index returns message threads for a phone number diff --git a/web/.env.production b/web/.env.production index 6718525e..30cbebd0 100644 --- a/web/.env.production +++ b/web/.env.production @@ -19,3 +19,6 @@ FIREBASE_APP_ID=1:877524083399:web:430d6a29a0d808946514e2 FIREBASE_MEASUREMENT_ID=G-EZ5W9DVK8T CLOUDFLARE_TURNSTILE_SITE_KEY=0x4AAAAAAA6Hpp8SDyMMPhWg + +PUSHER_KEY=a4809008d8f03aaab022 +PUSHER_CLUSTER=mt1 diff --git a/web/layouts/default.vue b/web/layouts/default.vue index 473f3aac..f2eea4a4 100644 --- a/web/layouts/default.vue +++ b/web/layouts/default.vue @@ -29,6 +29,7 @@ + diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index cf079e4c..8923424b 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -125,6 +125,9 @@ importers: '@nuxtjs/vuetify': specifier: ^1.12.3 version: 1.12.3(vue@2.7.16)(webpack@5.97.1) + '@types/qrcode': + specifier: ^1.5.5 + version: 1.5.5 '@vue/test-utils': specifier: ^1.3.6 version: 1.3.6(vue-template-compiler@2.7.16)(vue@2.7.16) @@ -3612,6 +3615,12 @@ packages: integrity: sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==, } + '@types/qrcode@1.5.5': + resolution: + { + integrity: sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==, + } + '@types/qs@6.9.8': resolution: { @@ -18178,6 +18187,10 @@ snapshots: '@types/pug@2.0.10': {} + '@types/qrcode@1.5.5': + dependencies: + '@types/node': 20.8.0 + '@types/qs@6.9.8': {} '@types/range-parser@1.2.5': {} diff --git a/web/store/index.ts b/web/store/index.ts index 8bca542b..debe6bf4 100644 --- a/web/store/index.ts +++ b/web/store/index.ts @@ -10,6 +10,7 @@ import { EntitiesDiscord, EntitiesMessage, EntitiesPhone, + EntitiesPhoneAPIKey, EntitiesUser, EntitiesWebhook, RequestsDiscordStore, @@ -22,6 +23,8 @@ import { ResponsesMessagesResponse, ResponsesNoContent, ResponsesOkString, + ResponsesPhoneAPIKeyResponse, + ResponsesPhoneAPIKeysResponse, ResponsesUnprocessableEntity, ResponsesUserResponse, ResponsesWebhookResponse, @@ -432,6 +435,119 @@ export const actions = { }) }, + storePhoneApiKey(context: ActionContext, name: string) { + return new Promise((resolve, reject) => { + axios + .post(`/v1/api-keys`, { name }) + .then(async (response: AxiosResponse) => { + await context.dispatch('addNotification', { + message: + response.data.message ?? 'Phone API Key created successfully', + type: 'success', + }) + resolve(response.data) + }) + .catch(async (error: AxiosError) => { + await Promise.all([ + context.dispatch('addNotification', { + message: + error.response?.data?.message ?? + 'Errors while creating phone API key', + type: 'error', + }), + ]) + reject(error) + }) + }) + }, + + indexPhoneApiKeys(context: ActionContext) { + return new Promise>((resolve, reject) => { + axios + .get(`/v1/api-keys`, { + params: { + limit: 100, + }, + }) + .then((response: AxiosResponse) => { + resolve(response.data.data) + }) + .catch(async (error: AxiosError) => { + await Promise.all([ + context.dispatch('addNotification', { + message: + (error.response?.data as any)?.message ?? + 'Error while fetching phone API keys', + type: 'error', + }), + ]) + reject(getErrorMessages(error)) + }) + }) + }, + + deletePhoneApiKey( + context: ActionContext, + phoneAPIKeyID: string, + ) { + return new Promise((resolve, reject) => { + axios + .delete(`/v1/api-keys/${phoneAPIKeyID}`) + .then(async (response: AxiosResponse) => { + await context.dispatch('addNotification', { + message: + response.data.message ?? + 'The phone API key has been deleted successfully', + type: 'success', + }) + resolve() + }) + .catch(async (error: AxiosError) => { + await Promise.all([ + context.dispatch('addNotification', { + message: + (error.response?.data as any)?.message ?? + 'Error while deleting phone API key', + type: 'error', + }), + ]) + reject(getErrorMessages(error)) + }) + }) + }, + + deletePhoneFromPhoneApiKey( + context: ActionContext, + payload: { phoneAPIKeyID: string; phoneID: string }, + ) { + return new Promise((resolve, reject) => { + axios + .delete( + `/v1/api-keys/${payload.phoneAPIKeyID}/phones/${payload.phoneID}`, + ) + .then(async (response: AxiosResponse) => { + await context.dispatch('addNotification', { + message: + response.data.message ?? + 'The phone has been removed from the phone API key successfully', + type: 'success', + }) + resolve() + }) + .catch(async (error: AxiosError) => { + await Promise.all([ + context.dispatch('addNotification', { + message: + (error.response?.data as any)?.message ?? + 'Error while deleting phone API key', + type: 'error', + }), + ]) + reject(getErrorMessages(error)) + }) + }) + }, + async handleAxiosError( context: ActionContext, error: AxiosError, From 06f5f300bf58889c12c11ab875bedd82f1a16fcf Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Tue, 22 Apr 2025 22:01:43 +0200 Subject: [PATCH 009/336] add pk_ prefix infront of phone API key --- api/pkg/services/phone_api_key_service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/pkg/services/phone_api_key_service.go b/api/pkg/services/phone_api_key_service.go index 172f5476..f718bd12 100644 --- a/api/pkg/services/phone_api_key_service.go +++ b/api/pkg/services/phone_api_key_service.go @@ -70,7 +70,7 @@ func (service *PhoneAPIKeyService) Create(ctx context.Context, authContext entit UserEmail: authContext.Email, PhoneNumbers: nil, PhoneIDs: nil, - APIKey: apiKey, + APIKey: "pk_" + apiKey, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), } From 94b5859681704fbdfed2b6797ec60760b8c9f5de Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Tue, 22 Apr 2025 23:31:20 +0200 Subject: [PATCH 010/336] Refactor the login code to send FCM token --- .../com/httpsms/FirebaseMessagingService.kt | 8 ++-- .../java/com/httpsms/HttpSmsApiService.kt | 44 +++++-------------- .../main/java/com/httpsms/LoginActivity.kt | 24 ++++++++-- .../src/main/java/com/httpsms/MainActivity.kt | 16 ++++--- 4 files changed, 46 insertions(+), 46 deletions(-) diff --git a/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt b/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt index b17e2038..8f1e448c 100644 --- a/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt +++ b/android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt @@ -98,15 +98,15 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { if (Settings.isLoggedIn(this)) { Timber.d("updating SIM1 phone with new fcm token") - val phone = HttpSmsApiService.create(this).updatePhone(Settings.getSIM1PhoneNumber(this), token, Constants.SIM1) - if (phone != null) { - Settings.setUserID(this, phone.userID) + val response = HttpSmsApiService.create(this).updateFcmToken(Settings.getSIM1PhoneNumber(this), Constants.SIM1, token) + if (response.first != null) { + Settings.setUserID(this, response.first!!.userID) } } if(Settings.isDualSIM(this)) { Timber.d("updating SIM2 phone with new fcm token") - HttpSmsApiService.create(this).updatePhone(Settings.getSIM2PhoneNumber(this), token, Constants.SIM2) + HttpSmsApiService.create(this).updateFcmToken(Settings.getSIM2PhoneNumber(this), Constants.SIM2, token) } } diff --git a/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt b/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt index 46c3c1e0..5f72dfad 100644 --- a/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt +++ b/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt @@ -129,7 +129,7 @@ class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) { return true } - fun storeHeartbeat(phoneNumbers: Array, charging: Boolean) { + fun storeHeartbeat(phoneNumbers: Array, charging: Boolean): Boolean { val body = """ { "charging": $charging, @@ -148,11 +148,12 @@ class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) { if (!response.isSuccessful) { Timber.e("error response [${response.body?.string()}] with code [${response.code}] while sending heartbeat [$body] for phone numbers [${phoneNumbers.joinToString()}]") response.close() - return + return false } response.close() Timber.i( "heartbeat stored successfully for phone numbers [${phoneNumbers.joinToString()}]" ) + return true } @@ -195,8 +196,7 @@ class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) { return true } - - fun updatePhone(phoneNumber: String, fcmToken: String, sim: String): Phone? { + fun updateFcmToken(phoneNumber: String, sim: String, fcmToken: String): Triple { val body = """ { "fcm_token": "$fcmToken", @@ -206,47 +206,27 @@ class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) { """.trimIndent() val request: Request = Request.Builder() - .url(resolveURL("/v1/phones")) + .url(resolveURL("/v1/phones/fcm-token")) .put(body.toRequestBody(jsonMediaType)) .header(apiKeyHeader, apiKey) .header(clientVersionHeader, BuildConfig.VERSION_NAME) .build() - val response = client.newCall(request).execute() - if (!response.isSuccessful) { - Timber.e("error response [${response.body?.string()}] with code [${response.code}] while sending fcm token [${body}]") - response.close() - return null - } - - val payload = ResponsePhone.fromJson(response.body!!.string())?.data - response.close() - Timber.i("fcm token sent successfully for phone [$phoneNumber] and id [${payload?.id}]" ) - return payload - } - - - fun validateApiKey(): Pair { - val request: Request = Request.Builder() - .url(resolveURL("/v1/users/me")) - .header(apiKeyHeader, apiKey) - .header(clientVersionHeader, BuildConfig.VERSION_NAME) - .get() - .build() - try { val response = client.newCall(request).execute() if (!response.isSuccessful) { - Timber.e("error response [${response.body?.string()}] with code [${response.code}] while verifying apiKey [$apiKey]") + Timber.e("error response [${response.body?.string()}] with code [${response.code}] while updating FCM token [$fcmToken] with apiKey [$apiKey]") response.close() - return Pair("Cannot validate the API key. Check if it is correct and try again.", null) + return Triple(null,"Cannot validate the API key. Check if it is correct and try again.", null) } response.close() - Timber.i("api key [$apiKey] and server url [$baseURL] are valid" ) - return Pair(null, null) + Timber.i("FCM token submitted correctly with API key [$apiKey] and server url [$baseURL]" ) + + val payload = ResponsePhone.fromJson(response.body!!.string())?.data + return Triple(payload, null, null) } catch (ex: Exception) { - return Pair(null, ex.message) + return Triple(null, null, ex.message) } } diff --git a/android/app/src/main/java/com/httpsms/LoginActivity.kt b/android/app/src/main/java/com/httpsms/LoginActivity.kt index 84232d0b..e666bd96 100644 --- a/android/app/src/main/java/com/httpsms/LoginActivity.kt +++ b/android/app/src/main/java/com/httpsms/LoginActivity.kt @@ -180,12 +180,18 @@ class LoginActivity : AppCompatActivity() { Timber.d("login button clicked") val error = isGooglePlayServicesAvailable() - if (error != null) { + if (error != null || Settings.getFcmToken(this) == null) { Timber.d("google play services not installed [${error}]") Toast.makeText(this, error, Toast.LENGTH_SHORT).show() return } + if (Settings.getFcmToken(this) == null) { + Timber.d("The FCM token is not set") + Toast.makeText(this, "Cannot find FCM token. Make sure you have google play services installed", Toast.LENGTH_LONG).show() + return + } + loginButton().isEnabled = false val progressBar = findViewById(R.id.loginProgressIndicator) progressBar.visibility = View.VISIBLE @@ -292,8 +298,20 @@ class LoginActivity : AppCompatActivity() { } Thread { - val response = HttpSmsApiService(apiKey.text.toString(), URI(serverUrl.text.toString().trim())).validateApiKey() - liveData.postValue(response) + val service = HttpSmsApiService(apiKey.text.toString(), URI(serverUrl.text.toString().trim())) + + var e164PhoneNumber = formatE164(phoneNumber.text.toString().trim()) + var response = service.updateFcmToken(e164PhoneNumber, Constants.SIM1, Settings.getFcmToken(this) ?: "") + if(response.second != null || response.third != null || !SmsManagerService.isDualSIM(this)) { + Timber.e("error updating fcm token [${response.second}]") + liveData.postValue(Pair(response.second, response.third)) + return@Thread + } + + e164PhoneNumber = formatE164(phoneNumberSIM2.text.toString().trim()) + response = service.updateFcmToken(e164PhoneNumber, Constants.SIM2, Settings.getFcmToken(this) ?: "") + + liveData.postValue(Pair(response.second, response.third)) Timber.d("finished validating api URL") }.start() } diff --git a/android/app/src/main/java/com/httpsms/MainActivity.kt b/android/app/src/main/java/com/httpsms/MainActivity.kt index 277fe674..d691ae6f 100644 --- a/android/app/src/main/java/com/httpsms/MainActivity.kt +++ b/android/app/src/main/java/com/httpsms/MainActivity.kt @@ -20,7 +20,6 @@ import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.MutableLiveData import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.ListenableWorker.Result import androidx.work.NetworkType import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager @@ -203,9 +202,9 @@ class MainActivity : AppCompatActivity() { private fun sendFCMToken(timestamp: Long, context:Context, phoneNumber: String, sim: String) { Thread { - val phone = HttpSmsApiService.create(context).updatePhone(phoneNumber, Settings.getFcmToken(context) ?: "", sim) - if (phone != null) { - Settings.setUserID(context, phone.userID) + val response = HttpSmsApiService.create(context).updateFcmToken(phoneNumber, sim,Settings.getFcmToken(context) ?: "") + if (response.first != null) { + Settings.setUserID(context, response.first!!.userID) Settings.setFcmTokenLastUpdateTimestampAsync(context, timestamp) Timber.i("[${sim}] FCM token uploaded successfully") return@Thread @@ -318,10 +317,10 @@ class MainActivity : AppCompatActivity() { if (exception != null) { Timber.w("heartbeat sending failed with [$exception]") - Toast.makeText(context, exception, Toast.LENGTH_SHORT).show() + Toast.makeText(context, exception, Toast.LENGTH_LONG).show() return@run } - Toast.makeText(context, "Heartbeat Sent", Toast.LENGTH_SHORT).show() + Toast.makeText(context, "Heartbeat sent successfully", Toast.LENGTH_SHORT).show() setLastHeartbeatTimestamp(this) } @@ -337,7 +336,10 @@ class MainActivity : AppCompatActivity() { phoneNumbers.add(Settings.getSIM2PhoneNumber(applicationContext)) } Timber.w("numbers = [${phoneNumbers.joinToString()}]") - HttpSmsApiService.create(context).storeHeartbeat(phoneNumbers.toTypedArray(), charging) + val isStored = HttpSmsApiService.create(context).storeHeartbeat(phoneNumbers.toTypedArray(), charging) + if (!isStored) { + error = "Could not send heartbeat make sure the phone is connected to the internet" + } Settings.setHeartbeatTimestampAsync(applicationContext, System.currentTimeMillis()) } catch (exception: Exception) { Timber.e(exception) From a9dc66bc6dd9c52b582178193d2ae618b60e4181 Mon Sep 17 00:00:00 2001 From: Acho Arnold Date: Wed, 23 Apr 2025 01:01:08 +0200 Subject: [PATCH 011/336] Use axiom for logs and fix bug with phone API key --- .../java/com/httpsms/HttpSmsApiService.kt | 3 +-- .../main/java/com/httpsms/LoginActivity.kt | 10 ++++++-- .../app/src/main/java/com/httpsms/LogzTree.kt | 7 +++--- .../src/main/java/com/httpsms/MainActivity.kt | 1 - api/pkg/di/container.go | 1 - .../gorm_phone_api_key_repository.go | 25 +++++++++---------- web/pages/phone-api-keys/index.vue | 8 +++--- web/store/index.ts | 4 +-- 8 files changed, 31 insertions(+), 28 deletions(-) diff --git a/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt b/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt index 5f72dfad..3389b186 100644 --- a/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt +++ b/android/app/src/main/java/com/httpsms/HttpSmsApiService.kt @@ -220,10 +220,9 @@ class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) { return Triple(null,"Cannot validate the API key. Check if it is correct and try again.", null) } - response.close() Timber.i("FCM token submitted correctly with API key [$apiKey] and server url [$baseURL]" ) - val payload = ResponsePhone.fromJson(response.body!!.string())?.data + response.close() return Triple(payload, null, null) } catch (ex: Exception) { return Triple(null, null, ex.message) diff --git a/android/app/src/main/java/com/httpsms/LoginActivity.kt b/android/app/src/main/java/com/httpsms/LoginActivity.kt index e666bd96..9f4b1a9b 100644 --- a/android/app/src/main/java/com/httpsms/LoginActivity.kt +++ b/android/app/src/main/java/com/httpsms/LoginActivity.kt @@ -302,12 +302,18 @@ class LoginActivity : AppCompatActivity() { var e164PhoneNumber = formatE164(phoneNumber.text.toString().trim()) var response = service.updateFcmToken(e164PhoneNumber, Constants.SIM1, Settings.getFcmToken(this) ?: "") - if(response.second != null || response.third != null || !SmsManagerService.isDualSIM(this)) { - Timber.e("error updating fcm token [${response.second}]") + if(response.second != null || response.third != null) { + Timber.e("error updating fcm token [${response.second}], third [${response.third}]") liveData.postValue(Pair(response.second, response.third)) return@Thread } + if (!SmsManagerService.isDualSIM(this)) { + Timber.d("single sim detected, no need to update sim2") + liveData.postValue(Pair(null, null)) + return@Thread + } + e164PhoneNumber = formatE164(phoneNumberSIM2.text.toString().trim()) response = service.updateFcmToken(e164PhoneNumber, Constants.SIM2, Settings.getFcmToken(this) ?: "") diff --git a/android/app/src/main/java/com/httpsms/LogzTree.kt b/android/app/src/main/java/com/httpsms/LogzTree.kt index c9cd69cb..68a81c8e 100644 --- a/android/app/src/main/java/com/httpsms/LogzTree.kt +++ b/android/app/src/main/java/com/httpsms/LogzTree.kt @@ -35,11 +35,12 @@ class LogzTree(val context: Context): Timber.DebugTree() { t ) - val body = Klaxon().toJsonString(logEntry).toRequestBody("application/x-www-form-urlencoded".toMediaType()) + val body = Klaxon().toJsonString(listOf(logEntry)).toRequestBody("application/json".toMediaType()) val request: Request = Request.Builder() - .url("https://listener.logz.io:8071?token=xPDQiZOOfemERsCaVsJXtMbhKfWdVyNk&type=http-bulk") + .url("https://api.axiom.co/v1/datasets/production/ingest") .post(body) - .header("Content-Type", "application/x-www-form-urlencoded") + .header("Content-Type", "application/json") + .header("Authorization", "Bearer xaat-2a2e0b73-3702-4971-a80f-be3956934950") .build() Thread { diff --git a/android/app/src/main/java/com/httpsms/MainActivity.kt b/android/app/src/main/java/com/httpsms/MainActivity.kt index d691ae6f..5f76ada8 100644 --- a/android/app/src/main/java/com/httpsms/MainActivity.kt +++ b/android/app/src/main/java/com/httpsms/MainActivity.kt @@ -335,7 +335,6 @@ class MainActivity : AppCompatActivity() { if (Settings.getActiveStatus(applicationContext, Constants.SIM2)) { phoneNumbers.add(Settings.getSIM2PhoneNumber(applicationContext)) } - Timber.w("numbers = [${phoneNumbers.joinToString()}]") val isStored = HttpSmsApiService.create(context).storeHeartbeat(phoneNumbers.toTypedArray(), charging) if (!isStored) { error = "Could not send heartbeat make sure the phone is connected to the internet" diff --git a/api/pkg/di/container.go b/api/pkg/di/container.go index cd21e074..bd8d089e 100644 --- a/api/pkg/di/container.go +++ b/api/pkg/di/container.go @@ -699,7 +699,6 @@ func (container *Container) PhoneAPIKeyRepository() (repository repositories.Pho container.Logger(), container.Tracer(), container.DB(), - container.UserRistrettoCache(), ) } diff --git a/api/pkg/repositories/gorm_phone_api_key_repository.go b/api/pkg/repositories/gorm_phone_api_key_repository.go index 86e4786b..692616eb 100644 --- a/api/pkg/repositories/gorm_phone_api_key_repository.go +++ b/api/pkg/repositories/gorm_phone_api_key_repository.go @@ -44,13 +44,13 @@ func (repository *gormPhoneAPIKeyRepository) RemovePhoneByID(ctx context.Context defer span.End() query := ` -UPDATE ? +UPDATE phone_api_keys SET phone_ids = array_remove(phone_ids, ?), phone_numbers = array_remove(phone_numbers, ?) WHERE user_id = ? AND array_position(phone_ids, ?) IS NOT NULL; ` err := repository.db.WithContext(ctx). - Raw(query, (entities.PhoneAPIKey{}).TableName(), phoneID, phoneNumber, userID, phoneID). + Exec(query, phoneID, phoneNumber, userID, phoneID). Error if err != nil { msg := fmt.Sprintf("cannot remove phone with ID [%s] and number [%s] for user with ID [%s] ", phoneID, phoneNumber, userID) @@ -103,7 +103,7 @@ func (repository *gormPhoneAPIKeyRepository) LoadAuthContext(ctx context.Context } phoneAPIKey := new(entities.PhoneAPIKey) - err := repository.db.WithContext(ctx).Where("api_key = ?", phoneAPIKey).First(apiKey).Error + err := repository.db.WithContext(ctx).Where("api_key = ?", apiKey).First(phoneAPIKey).Error if errors.Is(err, gorm.ErrRecordNotFound) { msg := fmt.Sprintf("phone api key [%s] does not exist", apiKey) return entities.AuthContext{}, repository.tracer.WrapErrorSpan(span, stacktrace.PropagateWithCode(err, ErrCodeNotFound, msg)) @@ -121,7 +121,7 @@ func (repository *gormPhoneAPIKeyRepository) LoadAuthContext(ctx context.Context PhoneNumbers: phoneAPIKey.PhoneNumbers, } - if result := repository.cache.SetWithTTL(apiKey, authUser, 1, 1*time.Hour); !result { + if result := repository.cache.SetWithTTL(apiKey, authUser, 1, 15*time.Second); !result { msg := fmt.Sprintf("cannot cache [%T] with ID [%s] and result [%t]", authUser, phoneAPIKey.ID, result) ctxLogger.Error(repository.tracer.WrapErrorSpan(span, stacktrace.NewError(msg))) } @@ -168,13 +168,13 @@ func (repository *gormPhoneAPIKeyRepository) AddPhone(ctx context.Context, phone err := crdbgorm.ExecuteTx(ctx, repository.db, nil, func(tx *gorm.DB) error { query := ` -UPDATE ? +UPDATE phone_api_keys SET phone_ids = array_remove(phone_ids, ?), phone_numbers = array_remove(phone_numbers, ?) WHERE user_id = ?; ` err := tx.WithContext(ctx). - Raw(query, phoneAPIKey.TableName(), phone.ID, phone.PhoneNumber, phone.UserID). + Exec(query, phone.ID, phone.PhoneNumber, phone.UserID). Error if err != nil { msg := fmt.Sprintf("cannot remove phone with ID [%s] from API Key with ID [%s] for user with ID [%s]", phone.ID, phoneAPIKey.ID, phone.UserID) @@ -182,18 +182,19 @@ WHERE user_id = ?; } query = ` -UPDATE ? +UPDATE phone_api_keys SET phone_ids = array_append(phone_ids, ?), phone_numbers = array_append(phone_numbers, ?) WHERE array_position(phone_ids, ?) IS NULL AND id = ?; ` - err = repository.db.WithContext(ctx). - Raw(query, phoneAPIKey.TableName(), phone.ID, phone.PhoneNumber, phoneAPIKey.ID). + err = tx.WithContext(ctx). + Exec(query, phone.ID, phone.PhoneNumber, phone.ID, phoneAPIKey.ID). Error if err != nil { msg := fmt.Sprintf("cannot add [%T] with ID [%s] from API Key with ID [%s] for user with ID [%s]", phone, phone.ID, phoneAPIKey.ID, phone.UserID) return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } + return nil }) if err != nil { @@ -209,21 +210,19 @@ func (repository *gormPhoneAPIKeyRepository) RemovePhone(ctx context.Context, ph defer span.End() query := ` -UPDATE ? +UPDATE phone_api_keys SET phone_ids = array_remove(phone_ids, ?), phone_numbers = array_remove(phone_numbers, ?) WHERE id = ?; ` err := repository.db.WithContext(ctx). - Raw(query, phoneAPIKey.TableName(), phone.ID, phone.PhoneNumber, phoneAPIKey.ID). + Exec(query, phone.ID, phone.PhoneNumber, phoneAPIKey.ID). Error if err != nil { msg := fmt.Sprintf("cannot remove phone with ID [%s] from phone API key with ID [%s]", phone.ID, phoneAPIKey.ID) return repository.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } - repository.cache.Del(phoneAPIKey.APIKey) - return nil } diff --git a/web/pages/phone-api-keys/index.vue b/web/pages/phone-api-keys/index.vue index 691edc0f..cfc1d9ff 100644 --- a/web/pages/phone-api-keys/index.vue +++ b/web/pages/phone-api-keys/index.vue @@ -119,7 +119,7 @@ {{ phoneApiKey.created_at | timestamp }} -