From c883fa71284927ad9350b308cd4a17d44f4f5659 Mon Sep 17 00:00:00 2001 From: Min Zeya Phyo Date: Wed, 22 Oct 2025 17:22:17 +0800 Subject: [PATCH] completed SMS gateway project --- .dockerignore | 51 + .gitattributes | 9 + .github/dependabot.yml | 12 + .github/workflows/ci.yml | 101 ++ .gitignore | 37 + .kamal/hooks/docker-setup.sample | 3 + .kamal/hooks/post-app-boot.sample | 3 + .kamal/hooks/post-deploy.sample | 14 + .kamal/hooks/post-proxy-reboot.sample | 3 + .kamal/hooks/pre-app-boot.sample | 3 + .kamal/hooks/pre-build.sample | 51 + .kamal/hooks/pre-connect.sample | 47 + .kamal/hooks/pre-deploy.sample | 122 ++ .kamal/hooks/pre-proxy-reboot.sample | 3 + .kamal/secrets | 17 + .rubocop.yml | 8 + .ruby-version | 1 + ADMIN_COMPLETE.md | 757 ++++++++++++ ADMIN_INTERFACE.md | 376 ++++++ ADMIN_QUICKSTART.md | 229 ++++ API_DOCUMENTATION.md | 1020 +++++++++++++++++ CABLE_DOCUMENTATION.md | 984 ++++++++++++++++ CLAUDE.md | 430 +++++++ Dockerfile | 72 ++ FIXES_APPLIED.md | 355 ++++++ GATEWAY_MANAGEMENT.md | 378 ++++++ GATEWAY_TESTING.md | 614 ++++++++++ Gemfile | 77 ++ Gemfile.lock | 447 ++++++++ JSONB_FIXES.md | 326 ++++++ NAMESPACE_FIX.md | 87 ++ PERMISSIONS_FIX.md | 183 +++ Procfile.dev | 2 + QR_CODE_SETUP.md | 449 ++++++++ QUICKSTART.md | 137 +++ README.md | 730 ++++++++++++ Rakefile | 6 + SESSION_MIDDLEWARE_FIX.md | 179 +++ STARTUP_GUIDE.md | 281 +++++ WEBSOCKET_FIX.md | 237 ++++ WEBSOCKET_SETUP.md | 502 ++++++++ app/assets/builds/.keep | 0 app/assets/images/.keep | 0 app/assets/stylesheets/application.css | 10 + app/assets/tailwind/application.css | 36 + app/channels/application_cable/channel.rb | 4 + app/channels/application_cable/connection.rb | 31 + app/channels/gateway_channel.rb | 99 ++ app/controllers/admin/api_keys_controller.rb | 76 ++ .../admin/api_tester_controller.rb | 8 + app/controllers/admin/base_controller.rb | 30 + app/controllers/admin/dashboard_controller.rb | 20 + app/controllers/admin/gateways_controller.rb | 152 +++ app/controllers/admin/logs_controller.rb | 37 + app/controllers/admin/sessions_controller.rb | 38 + .../api/v1/admin/gateways_controller.rb | 49 + .../api/v1/admin/stats_controller.rb | 60 + .../api/v1/gateway/base_controller.rb | 10 + .../api/v1/gateway/heartbeats_controller.rb | 30 + .../v1/gateway/registrations_controller.rb | 53 + .../api/v1/gateway/sms_controller.rb | 61 + app/controllers/api/v1/otp_controller.rb | 86 ++ app/controllers/api/v1/sms_controller.rb | 90 ++ app/controllers/application_controller.rb | 22 + app/controllers/concerns/.keep | 0 .../concerns/api_authenticatable.rb | 73 ++ app/controllers/concerns/rate_limitable.rb | 54 + app/helpers/admin_helper.rb | 9 + app/helpers/application_helper.rb | 12 + app/javascript/application.js | 3 + app/javascript/controllers/application.js | 9 + .../controllers/hello_controller.js | 7 + app/javascript/controllers/index.js | 4 + app/jobs/application_job.rb | 7 + app/jobs/check_gateway_health_job.rb | 14 + app/jobs/cleanup_expired_otps_job.rb | 8 + app/jobs/process_inbound_sms_job.rb | 57 + app/jobs/reset_daily_counters_job.rb | 8 + app/jobs/retry_failed_sms_job.rb | 22 + app/jobs/send_sms_job.rb | 41 + app/jobs/trigger_webhook_job.rb | 31 + app/mailers/application_mailer.rb | 4 + app/models/admin_user.rb | 11 + app/models/api_key.rb | 72 ++ app/models/application_record.rb | 3 + app/models/concerns/.keep | 0 app/models/concerns/metrics.rb | 81 ++ app/models/gateway.rb | 68 ++ app/models/otp_code.rb | 118 ++ app/models/sms_message.rb | 110 ++ app/models/webhook_config.rb | 54 + app/views/admin/api_keys/index.html.erb | 101 ++ app/views/admin/api_keys/new.html.erb | 105 ++ app/views/admin/api_keys/show.html.erb | 139 +++ app/views/admin/api_tester/index.html.erb | 466 ++++++++ app/views/admin/dashboard/index.html.erb | 230 ++++ app/views/admin/gateways/index.html.erb | 168 +++ app/views/admin/gateways/new.html.erb | 87 ++ app/views/admin/gateways/show.html.erb | 565 +++++++++ app/views/admin/gateways/test.html.erb | 353 ++++++ app/views/admin/logs/index.html.erb | 235 ++++ app/views/admin/sessions/new.html.erb | 49 + app/views/layouts/admin.html.erb | 124 ++ app/views/layouts/application.html.erb | 30 + app/views/layouts/mailer.html.erb | 13 + app/views/layouts/mailer.text.erb | 1 + app/views/pwa/manifest.json.erb | 22 + app/views/pwa/service-worker.js | 26 + bin/brakeman | 7 + bin/bundle | 109 ++ bin/dev | 16 + bin/docker-entrypoint | 14 + bin/importmap | 4 + bin/jobs | 6 + bin/kamal | 27 + bin/rails | 4 + bin/rake | 4 + bin/rubocop | 8 + bin/setup | 34 + bin/thrust | 5 + config.ru | 6 + config/application.rb | 40 + config/boot.rb | 4 + config/cable.yml | 13 + config/cache.yml | 16 + config/credentials.yml.enc | 1 + config/database.yml | 98 ++ config/deploy.yml | 116 ++ config/environment.rb | 5 + config/environments/development.rb | 81 ++ config/environments/production.rb | 90 ++ config/environments/test.rb | 53 + config/importmap.rb | 7 + config/initializers/assets.rb | 7 + .../initializers/content_security_policy.rb | 25 + config/initializers/cors.rb | 17 + .../initializers/filter_parameter_logging.rb | 8 + config/initializers/inflections.rb | 16 + config/initializers/pagy.rb | 33 + config/initializers/phonelib.rb | 2 + config/initializers/session_store.rb | 3 + config/initializers/sidekiq.rb | 19 + config/locales/en.yml | 31 + config/puma.rb | 41 + config/queue.yml | 18 + config/recurring.yml | 15 + config/routes.rb | 73 ++ config/sidekiq_cron.yml | 20 + config/storage.yml | 34 + db/cable_schema.rb | 11 + db/cache_schema.rb | 14 + db/migrate/20251019070342_create_gateways.rb | 24 + .../20251019070519_create_sms_messages.rb | 26 + db/migrate/20251019070520_create_otp_codes.rb | 21 + .../20251019070521_create_webhook_configs.rb | 18 + db/migrate/20251019070522_create_api_keys.rb | 19 + db/migrate/20251020025135_create_admins.rb | 14 + ...1020031401_rename_admins_to_admin_users.rb | 5 + db/queue_schema.rb | 129 +++ db/schema.rb | 117 ++ db/seeds.rb | 143 +++ lib/tasks/.keep | 0 log/.keep | 0 public/400.html | 114 ++ public/404.html | 114 ++ public/406-unsupported-browser.html | 114 ++ public/422.html | 114 ++ public/500.html | 114 ++ public/icon.png | Bin 0 -> 4166 bytes public/icon.svg | 3 + public/robots.txt | 1 + script/.keep | 0 storage/.keep | 0 test/application_system_test_case.rb | 5 + test/controllers/.keep | 0 test/fixtures/admin_users.yml | 13 + test/fixtures/files/.keep | 0 test/helpers/.keep | 0 test/integration/.keep | 0 test/mailers/.keep | 0 test/models/.keep | 0 test/models/admin_user_test.rb | 7 + test/system/.keep | 0 test/test_helper.rb | 15 + test_gateway_auth.rb | 70 ++ tmp/.keep | 0 tmp/pids/.keep | 0 tmp/storage/.keep | 0 vendor/.keep | 0 vendor/javascript/.keep | 0 190 files changed, 16294 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitattributes create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100755 .kamal/hooks/docker-setup.sample create mode 100755 .kamal/hooks/post-app-boot.sample create mode 100755 .kamal/hooks/post-deploy.sample create mode 100755 .kamal/hooks/post-proxy-reboot.sample create mode 100755 .kamal/hooks/pre-app-boot.sample create mode 100755 .kamal/hooks/pre-build.sample create mode 100755 .kamal/hooks/pre-connect.sample create mode 100755 .kamal/hooks/pre-deploy.sample create mode 100755 .kamal/hooks/pre-proxy-reboot.sample create mode 100644 .kamal/secrets create mode 100644 .rubocop.yml create mode 100644 .ruby-version create mode 100644 ADMIN_COMPLETE.md create mode 100644 ADMIN_INTERFACE.md create mode 100644 ADMIN_QUICKSTART.md create mode 100644 API_DOCUMENTATION.md create mode 100644 CABLE_DOCUMENTATION.md create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 FIXES_APPLIED.md create mode 100644 GATEWAY_MANAGEMENT.md create mode 100644 GATEWAY_TESTING.md create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 JSONB_FIXES.md create mode 100644 NAMESPACE_FIX.md create mode 100644 PERMISSIONS_FIX.md create mode 100644 Procfile.dev create mode 100644 QR_CODE_SETUP.md create mode 100644 QUICKSTART.md create mode 100644 README.md create mode 100644 Rakefile create mode 100644 SESSION_MIDDLEWARE_FIX.md create mode 100644 STARTUP_GUIDE.md create mode 100644 WEBSOCKET_FIX.md create mode 100644 WEBSOCKET_SETUP.md create mode 100644 app/assets/builds/.keep create mode 100644 app/assets/images/.keep create mode 100644 app/assets/stylesheets/application.css create mode 100644 app/assets/tailwind/application.css create mode 100644 app/channels/application_cable/channel.rb create mode 100644 app/channels/application_cable/connection.rb create mode 100644 app/channels/gateway_channel.rb create mode 100644 app/controllers/admin/api_keys_controller.rb create mode 100644 app/controllers/admin/api_tester_controller.rb create mode 100644 app/controllers/admin/base_controller.rb create mode 100644 app/controllers/admin/dashboard_controller.rb create mode 100644 app/controllers/admin/gateways_controller.rb create mode 100644 app/controllers/admin/logs_controller.rb create mode 100644 app/controllers/admin/sessions_controller.rb create mode 100644 app/controllers/api/v1/admin/gateways_controller.rb create mode 100644 app/controllers/api/v1/admin/stats_controller.rb create mode 100644 app/controllers/api/v1/gateway/base_controller.rb create mode 100644 app/controllers/api/v1/gateway/heartbeats_controller.rb create mode 100644 app/controllers/api/v1/gateway/registrations_controller.rb create mode 100644 app/controllers/api/v1/gateway/sms_controller.rb create mode 100644 app/controllers/api/v1/otp_controller.rb create mode 100644 app/controllers/api/v1/sms_controller.rb create mode 100644 app/controllers/application_controller.rb create mode 100644 app/controllers/concerns/.keep create mode 100644 app/controllers/concerns/api_authenticatable.rb create mode 100644 app/controllers/concerns/rate_limitable.rb create mode 100644 app/helpers/admin_helper.rb create mode 100644 app/helpers/application_helper.rb create mode 100644 app/javascript/application.js create mode 100644 app/javascript/controllers/application.js create mode 100644 app/javascript/controllers/hello_controller.js create mode 100644 app/javascript/controllers/index.js create mode 100644 app/jobs/application_job.rb create mode 100644 app/jobs/check_gateway_health_job.rb create mode 100644 app/jobs/cleanup_expired_otps_job.rb create mode 100644 app/jobs/process_inbound_sms_job.rb create mode 100644 app/jobs/reset_daily_counters_job.rb create mode 100644 app/jobs/retry_failed_sms_job.rb create mode 100644 app/jobs/send_sms_job.rb create mode 100644 app/jobs/trigger_webhook_job.rb create mode 100644 app/mailers/application_mailer.rb create mode 100644 app/models/admin_user.rb create mode 100644 app/models/api_key.rb create mode 100644 app/models/application_record.rb create mode 100644 app/models/concerns/.keep create mode 100644 app/models/concerns/metrics.rb create mode 100644 app/models/gateway.rb create mode 100644 app/models/otp_code.rb create mode 100644 app/models/sms_message.rb create mode 100644 app/models/webhook_config.rb create mode 100644 app/views/admin/api_keys/index.html.erb create mode 100644 app/views/admin/api_keys/new.html.erb create mode 100644 app/views/admin/api_keys/show.html.erb create mode 100644 app/views/admin/api_tester/index.html.erb create mode 100644 app/views/admin/dashboard/index.html.erb create mode 100644 app/views/admin/gateways/index.html.erb create mode 100644 app/views/admin/gateways/new.html.erb create mode 100644 app/views/admin/gateways/show.html.erb create mode 100644 app/views/admin/gateways/test.html.erb create mode 100644 app/views/admin/logs/index.html.erb create mode 100644 app/views/admin/sessions/new.html.erb create mode 100644 app/views/layouts/admin.html.erb create mode 100644 app/views/layouts/application.html.erb create mode 100644 app/views/layouts/mailer.html.erb create mode 100644 app/views/layouts/mailer.text.erb create mode 100644 app/views/pwa/manifest.json.erb create mode 100644 app/views/pwa/service-worker.js create mode 100755 bin/brakeman create mode 100755 bin/bundle create mode 100755 bin/dev create mode 100755 bin/docker-entrypoint create mode 100755 bin/importmap create mode 100755 bin/jobs create mode 100755 bin/kamal create mode 100755 bin/rails create mode 100755 bin/rake create mode 100755 bin/rubocop create mode 100755 bin/setup create mode 100755 bin/thrust create mode 100644 config.ru create mode 100644 config/application.rb create mode 100644 config/boot.rb create mode 100644 config/cable.yml create mode 100644 config/cache.yml create mode 100644 config/credentials.yml.enc create mode 100644 config/database.yml create mode 100644 config/deploy.yml create mode 100644 config/environment.rb create mode 100644 config/environments/development.rb create mode 100644 config/environments/production.rb create mode 100644 config/environments/test.rb create mode 100644 config/importmap.rb create mode 100644 config/initializers/assets.rb create mode 100644 config/initializers/content_security_policy.rb create mode 100644 config/initializers/cors.rb create mode 100644 config/initializers/filter_parameter_logging.rb create mode 100644 config/initializers/inflections.rb create mode 100644 config/initializers/pagy.rb create mode 100644 config/initializers/phonelib.rb create mode 100644 config/initializers/session_store.rb create mode 100644 config/initializers/sidekiq.rb create mode 100644 config/locales/en.yml create mode 100644 config/puma.rb create mode 100644 config/queue.yml create mode 100644 config/recurring.yml create mode 100644 config/routes.rb create mode 100644 config/sidekiq_cron.yml create mode 100644 config/storage.yml create mode 100644 db/cable_schema.rb create mode 100644 db/cache_schema.rb create mode 100644 db/migrate/20251019070342_create_gateways.rb create mode 100644 db/migrate/20251019070519_create_sms_messages.rb create mode 100644 db/migrate/20251019070520_create_otp_codes.rb create mode 100644 db/migrate/20251019070521_create_webhook_configs.rb create mode 100644 db/migrate/20251019070522_create_api_keys.rb create mode 100644 db/migrate/20251020025135_create_admins.rb create mode 100644 db/migrate/20251020031401_rename_admins_to_admin_users.rb create mode 100644 db/queue_schema.rb create mode 100644 db/schema.rb create mode 100644 db/seeds.rb create mode 100644 lib/tasks/.keep create mode 100644 log/.keep create mode 100644 public/400.html create mode 100644 public/404.html create mode 100644 public/406-unsupported-browser.html create mode 100644 public/422.html create mode 100644 public/500.html create mode 100644 public/icon.png create mode 100644 public/icon.svg create mode 100644 public/robots.txt create mode 100644 script/.keep create mode 100644 storage/.keep create mode 100644 test/application_system_test_case.rb create mode 100644 test/controllers/.keep create mode 100644 test/fixtures/admin_users.yml create mode 100644 test/fixtures/files/.keep create mode 100644 test/helpers/.keep create mode 100644 test/integration/.keep create mode 100644 test/mailers/.keep create mode 100644 test/models/.keep create mode 100644 test/models/admin_user_test.rb create mode 100644 test/system/.keep create mode 100644 test/test_helper.rb create mode 100644 test_gateway_auth.rb create mode 100644 tmp/.keep create mode 100644 tmp/pids/.keep create mode 100644 tmp/storage/.keep create mode 100644 vendor/.keep create mode 100644 vendor/javascript/.keep diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..325bfc0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,51 @@ +# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files. + +# Ignore git directory. +/.git/ +/.gitignore + +# Ignore bundler config. +/.bundle + +# Ignore all environment files. +/.env* + +# Ignore all default key files. +/config/master.key +/config/credentials/*.key + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/.keep + +# Ignore assets. +/node_modules/ +/app/assets/builds/* +!/app/assets/builds/.keep +/public/assets + +# Ignore CI service files. +/.github + +# Ignore Kamal files. +/config/deploy*.yml +/.kamal + +# Ignore development files +/.devcontainer + +# Ignore Docker-related files +/.dockerignore +/Dockerfile* diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8dc4323 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# See https://git-scm.com/docs/gitattributes for more about git attribute files. + +# Mark the database schema as having been generated. +db/schema.rb linguist-generated + +# Mark any vendored files as having been vendored. +vendor/* linguist-vendored +config/credentials/*.yml.enc diff=rails_credentials +config/credentials.yml.enc diff=rails_credentials diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f0527e6 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: +- package-ecosystem: bundler + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 +- package-ecosystem: github-actions + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..37adcd3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,101 @@ +name: CI + +on: + pull_request: + push: + branches: [ main ] + +jobs: + scan_ruby: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Scan for common Rails security vulnerabilities using static analysis + run: bin/brakeman --no-pager + + scan_js: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Scan for security vulnerabilities in JavaScript dependencies + run: bin/importmap audit + + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Lint code for consistent style + run: bin/rubocop -f github + + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3 + + # redis: + # image: redis + # ports: + # - 6379:6379 + # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 + + steps: + - name: Install packages + run: sudo apt-get update && sudo apt-get install --no-install-recommends -y build-essential git libpq-dev libyaml-dev pkg-config google-chrome-stable + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: .ruby-version + bundler-cache: true + + - name: Run tests + env: + RAILS_ENV: test + DATABASE_URL: postgres://postgres:postgres@localhost:5432 + # REDIS_URL: redis://localhost:6379/0 + run: bin/rails db:test:prepare test test:system + + - name: Keep screenshots from failed system tests + uses: actions/upload-artifact@v4 + if: failure() + with: + name: screenshots + path: ${{ github.workspace }}/tmp/screenshots + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1303e39 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# See https://help.github.com/articles/ignoring-files for more about ignoring files. +# +# Temporary files generated by your text editor or operating system +# belong in git's global ignore instead: +# `$XDG_CONFIG_HOME/git/ignore` or `~/.config/git/ignore` + +# Ignore bundler config. +/.bundle + +# Ignore all environment files. +/.env* + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/ +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/ +!/tmp/storage/.keep + +/public/assets + +# Ignore master key for decrypting credentials and more. +/config/master.key + +/app/assets/builds/* +!/app/assets/builds/.keep diff --git a/.kamal/hooks/docker-setup.sample b/.kamal/hooks/docker-setup.sample new file mode 100755 index 0000000..2fb07d7 --- /dev/null +++ b/.kamal/hooks/docker-setup.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Docker set up on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/post-app-boot.sample b/.kamal/hooks/post-app-boot.sample new file mode 100755 index 0000000..70f9c4b --- /dev/null +++ b/.kamal/hooks/post-app-boot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Booted app version $KAMAL_VERSION on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/post-deploy.sample b/.kamal/hooks/post-deploy.sample new file mode 100755 index 0000000..fd364c2 --- /dev/null +++ b/.kamal/hooks/post-deploy.sample @@ -0,0 +1,14 @@ +#!/bin/sh + +# A sample post-deploy hook +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) +# KAMAL_RUNTIME + +echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds" diff --git a/.kamal/hooks/post-proxy-reboot.sample b/.kamal/hooks/post-proxy-reboot.sample new file mode 100755 index 0000000..1435a67 --- /dev/null +++ b/.kamal/hooks/post-proxy-reboot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Rebooted kamal-proxy on $KAMAL_HOSTS" diff --git a/.kamal/hooks/pre-app-boot.sample b/.kamal/hooks/pre-app-boot.sample new file mode 100755 index 0000000..45f7355 --- /dev/null +++ b/.kamal/hooks/pre-app-boot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Booting app version $KAMAL_VERSION on $KAMAL_HOSTS..." diff --git a/.kamal/hooks/pre-build.sample b/.kamal/hooks/pre-build.sample new file mode 100755 index 0000000..c5a5567 --- /dev/null +++ b/.kamal/hooks/pre-build.sample @@ -0,0 +1,51 @@ +#!/bin/sh + +# A sample pre-build hook +# +# Checks: +# 1. We have a clean checkout +# 2. A remote is configured +# 3. The branch has been pushed to the remote +# 4. The version we are deploying matches the remote +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) + +if [ -n "$(git status --porcelain)" ]; then + echo "Git checkout is not clean, aborting..." >&2 + git status --porcelain >&2 + exit 1 +fi + +first_remote=$(git remote) + +if [ -z "$first_remote" ]; then + echo "No git remote set, aborting..." >&2 + exit 1 +fi + +current_branch=$(git branch --show-current) + +if [ -z "$current_branch" ]; then + echo "Not on a git branch, aborting..." >&2 + exit 1 +fi + +remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1) + +if [ -z "$remote_head" ]; then + echo "Branch not pushed to remote, aborting..." >&2 + exit 1 +fi + +if [ "$KAMAL_VERSION" != "$remote_head" ]; then + echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2 + exit 1 +fi + +exit 0 diff --git a/.kamal/hooks/pre-connect.sample b/.kamal/hooks/pre-connect.sample new file mode 100755 index 0000000..77744bd --- /dev/null +++ b/.kamal/hooks/pre-connect.sample @@ -0,0 +1,47 @@ +#!/usr/bin/env ruby + +# A sample pre-connect check +# +# Warms DNS before connecting to hosts in parallel +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) +# KAMAL_RUNTIME + +hosts = ENV["KAMAL_HOSTS"].split(",") +results = nil +max = 3 + +elapsed = Benchmark.realtime do + results = hosts.map do |host| + Thread.new do + tries = 1 + + begin + Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME) + rescue SocketError + if tries < max + puts "Retrying DNS warmup: #{host}" + tries += 1 + sleep rand + retry + else + puts "DNS warmup failed: #{host}" + host + end + end + + tries + end + end.map(&:value) +end + +retries = results.sum - hosts.size +nopes = results.count { |r| r == max } + +puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ] diff --git a/.kamal/hooks/pre-deploy.sample b/.kamal/hooks/pre-deploy.sample new file mode 100755 index 0000000..05b3055 --- /dev/null +++ b/.kamal/hooks/pre-deploy.sample @@ -0,0 +1,122 @@ +#!/usr/bin/env ruby + +# A sample pre-deploy hook +# +# Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds. +# +# Fails unless the combined status is "success" +# +# These environment variables are available: +# KAMAL_RECORDED_AT +# KAMAL_PERFORMER +# KAMAL_VERSION +# KAMAL_HOSTS +# KAMAL_COMMAND +# KAMAL_SUBCOMMAND +# KAMAL_ROLES (if set) +# KAMAL_DESTINATION (if set) + +# Only check the build status for production deployments +if ENV["KAMAL_COMMAND"] == "rollback" || ENV["KAMAL_DESTINATION"] != "production" + exit 0 +end + +require "bundler/inline" + +# true = install gems so this is fast on repeat invocations +gemfile(true, quiet: true) do + source "https://rubygems.org" + + gem "octokit" + gem "faraday-retry" +end + +MAX_ATTEMPTS = 72 +ATTEMPTS_GAP = 10 + +def exit_with_error(message) + $stderr.puts message + exit 1 +end + +class GithubStatusChecks + attr_reader :remote_url, :git_sha, :github_client, :combined_status + + def initialize + @remote_url = github_repo_from_remote_url + @git_sha = `git rev-parse HEAD`.strip + @github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"]) + refresh! + end + + def refresh! + @combined_status = github_client.combined_status(remote_url, git_sha) + end + + def state + combined_status[:state] + end + + def first_status_url + first_status = combined_status[:statuses].find { |status| status[:state] == state } + first_status && first_status[:target_url] + end + + def complete_count + combined_status[:statuses].count { |status| status[:state] != "pending"} + end + + def total_count + combined_status[:statuses].count + end + + def current_status + if total_count > 0 + "Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ..." + else + "Build not started..." + end + end + + private + def github_repo_from_remote_url + url = `git config --get remote.origin.url`.strip.delete_suffix(".git") + if url.start_with?("https://github.com/") + url.delete_prefix("https://github.com/") + elsif url.start_with?("git@github.com:") + url.delete_prefix("git@github.com:") + else + url + end + end +end + + +$stdout.sync = true + +begin + puts "Checking build status..." + + attempts = 0 + checks = GithubStatusChecks.new + + loop do + case checks.state + when "success" + puts "Checks passed, see #{checks.first_status_url}" + exit 0 + when "failure" + exit_with_error "Checks failed, see #{checks.first_status_url}" + when "pending" + attempts += 1 + end + + exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS + + puts checks.current_status + sleep(ATTEMPTS_GAP) + checks.refresh! + end +rescue Octokit::NotFound + exit_with_error "Build status could not be found" +end diff --git a/.kamal/hooks/pre-proxy-reboot.sample b/.kamal/hooks/pre-proxy-reboot.sample new file mode 100755 index 0000000..061f805 --- /dev/null +++ b/.kamal/hooks/pre-proxy-reboot.sample @@ -0,0 +1,3 @@ +#!/bin/sh + +echo "Rebooting kamal-proxy on $KAMAL_HOSTS..." diff --git a/.kamal/secrets b/.kamal/secrets new file mode 100644 index 0000000..9a771a3 --- /dev/null +++ b/.kamal/secrets @@ -0,0 +1,17 @@ +# Secrets defined here are available for reference under registry/password, env/secret, builder/secrets, +# and accessories/*/env/secret in config/deploy.yml. All secrets should be pulled from either +# password manager, ENV, or a file. DO NOT ENTER RAW CREDENTIALS HERE! This file needs to be safe for git. + +# Example of extracting secrets from 1password (or another compatible pw manager) +# SECRETS=$(kamal secrets fetch --adapter 1password --account your-account --from Vault/Item KAMAL_REGISTRY_PASSWORD RAILS_MASTER_KEY) +# KAMAL_REGISTRY_PASSWORD=$(kamal secrets extract KAMAL_REGISTRY_PASSWORD ${SECRETS}) +# RAILS_MASTER_KEY=$(kamal secrets extract RAILS_MASTER_KEY ${SECRETS}) + +# Use a GITHUB_TOKEN if private repositories are needed for the image +# GITHUB_TOKEN=$(gh config get -h github.com oauth_token) + +# Grab the registry password from ENV +KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD + +# Improve security by using a password manager. Never check config/master.key into git! +RAILS_MASTER_KEY=$(cat config/master.key) diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..f9d86d4 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,8 @@ +# Omakase Ruby styling for Rails +inherit_gem: { rubocop-rails-omakase: rubocop.yml } + +# Overwrite or add rules to create your own house style +# +# # Use `[a, [b, c]]` not `[ a, [ b, c ] ]` +# Layout/SpaceInsideArrayLiteralBrackets: +# Enabled: false diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..fdeaef8 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +ruby-3.4.7 diff --git a/ADMIN_COMPLETE.md b/ADMIN_COMPLETE.md new file mode 100644 index 0000000..72526f1 --- /dev/null +++ b/ADMIN_COMPLETE.md @@ -0,0 +1,757 @@ +# Complete Admin Interface Implementation + +## Summary + +This document summarizes all the work completed to build a full-featured admin interface for MySMSAPio, including API key management, gateway management, SMS logs, and QR code-based configuration. + +--- + +## Features Implemented + +### 1. ✅ Admin Authentication System + +**What was built**: +- Secure login/logout system +- Session-based authentication +- Bcrypt password hashing +- Flash message support +- Helper methods for authentication checks + +**Files created/modified**: +- `app/models/admin_user.rb` (renamed from `admin.rb` to avoid namespace conflict) +- `app/controllers/admin/base_controller.rb` +- `app/controllers/admin/sessions_controller.rb` +- `app/views/admin/sessions/new.html.erb` +- `app/helpers/application_helper.rb` +- Migration: `db/migrate/*_rename_admins_to_admin_users.rb` + +**Access**: +- URL: `/admin/login` +- Default credentials: `admin@example.com` / `password123` + +--- + +### 2. ✅ Professional UI with Tailwind CSS + +**What was implemented**: +- Modern Tailwind CSS v4 design system +- Dark gradient sidebar navigation +- Responsive layouts (mobile/tablet/desktop) +- Font Awesome 6.4.0 icons +- Animated status indicators (pulse effects) +- Smooth transitions and hover effects +- Professional color scheme + +**Visual Features**: +- Card-based layouts +- Badge components for status +- Table designs with hover effects +- Form styling with icons +- Button states and loading indicators + +**Files**: +- `app/assets/tailwind/application.css` +- `app/views/layouts/admin.html.erb` +- All admin view files + +--- + +### 3. ✅ Dashboard + +**Features**: +- Real-time statistics: + - Total gateways (with online count) + - Active API keys count + - Messages sent today + - Messages received today +- Recent messages table (last 10) +- Gateway status overview +- Color-coded status badges + +**URL**: `/admin/dashboard` or `/admin` + +**Files**: +- `app/controllers/admin/dashboard_controller.rb` +- `app/views/admin/dashboard/index.html.erb` + +--- + +### 4. ✅ API Keys Management + +**Features**: +- List all API keys with: + - Name and key prefix + - Permissions (as badges) + - Active/Revoked/Expired status + - Last used timestamp + - Creation date +- Create new API keys: + - Name input + - Permission checkboxes (Send SMS, Receive SMS, Manage Gateways, Manage OTP) + - Optional expiration date +- One-time API key display: + - Full key shown only once after creation + - Copy to clipboard button with feedback + - Session-based security (not in URL) +- Revoke keys with confirmation + +**URLs**: +- List: `/admin/api_keys` +- Create: `/admin/api_keys/new` +- Show (after creation): `/admin/api_keys/:id` + +**Files**: +- `app/controllers/admin/api_keys_controller.rb` +- `app/views/admin/api_keys/index.html.erb` +- `app/views/admin/api_keys/new.html.erb` +- `app/views/admin/api_keys/show.html.erb` + +**Security**: +- API keys are SHA256 hashed +- Raw keys shown only once +- Stored in session temporarily +- CSRF protection enabled + +--- + +### 5. ✅ Gateway Management + +**Features**: + +**List Gateways** (`/admin/gateways`): +- Table showing all gateways +- Online/Offline status with animated pulse +- Active/Inactive toggles +- Priority levels +- Message statistics (today and total) +- Last heartbeat timestamps +- Device IDs +- "Register New Gateway" button + +**Create Gateway** (`/admin/gateways/new`): +- Device ID input +- Gateway name input +- Priority selector (1-10) +- Automatic API key generation +- Info box explaining the process + +**Gateway Success Page** (after creation): +- **QR Code Display**: + - High-quality SVG QR code + - Contains: API key, API base URL, WebSocket URL + - Scannable with Android app + - Auto-configuration support +- **Manual Configuration**: + - API Base URL (with copy button) + - WebSocket URL (with copy button) + - API Key (with copy button) + - "Copy All" button +- **Gateway Details**: + - Device ID, Name, Priority + - Status (offline until connected) + - Creation timestamp +- **Setup Instructions**: + - Option 1: QR code scanning (recommended) + - Option 2: Manual entry + - Step-by-step guide + +**View Gateway Details** (`/admin/gateways/:id`): +- Connection status dashboard +- Statistics cards: + - Online/Offline status + - Active/Inactive status + - Priority level + - Messages sent/received today + - Total messages +- Gateway information: + - Device ID + - Name, Status, Priority + - Last heartbeat (with time ago) + - Message counters + - Creation/update timestamps + - Device metadata (JSON display) +- Recent messages table (last 20) +- Activate/Deactivate button + +**Files**: +- `app/controllers/admin/gateways_controller.rb` +- `app/views/admin/gateways/index.html.erb` +- `app/views/admin/gateways/new.html.erb` +- `app/views/admin/gateways/show.html.erb` + +**Dependencies**: +- `rqrcode` gem v2.0+ for QR code generation + +--- + +### 6. ✅ SMS Logs + +**Features**: +- Paginated message list (50 per page, using Pagy) +- Advanced filtering: + - Direction (Inbound/Outbound/All) + - Status (Pending/Sent/Delivered/Failed/All) + - Phone number search + - Gateway filter + - Date range (from/to) + - Search button +- Message display: + - Message ID (truncated with code style) + - Phone number + - Direction badge (Inbound/Outbound with icons) + - Status badge (color-coded) + - Gateway name (if assigned) + - Message preview (truncated) + - Retry count (if failed) + - Timestamp +- Expandable error messages (click to view full error) +- Empty state with helpful message + +**URL**: `/admin/logs` + +**Files**: +- `app/controllers/admin/logs_controller.rb` +- `app/views/admin/logs/index.html.erb` + +--- + +### 7. ✅ QR Code Configuration System + +**What was implemented**: +- Automatic QR code generation on gateway creation +- JSON payload with all configuration data: + ```json + { + "api_key": "gw_live_...", + "api_base_url": "http://localhost:3000", + "websocket_url": "ws://localhost:3000/cable", + "version": "1.0" + } + ``` +- High error correction level (L=H) +- SVG format for quality scaling +- Automatic URL detection (HTTP→WS, HTTPS→WSS) +- Copy to clipboard for individual fields +- "Copy All" button for complete configuration + +**Benefits**: +- No manual typing of long API keys +- Reduces configuration errors +- Faster gateway setup (scan QR code → done) +- Works offline (QR code doesn't need network) + +**Gem Added**: +```ruby +gem "rqrcode", "~> 2.0" +``` + +--- + +## Bug Fixes Applied + +### Issue 1: Namespace Conflict - "Admin is not a module" + +**Problem**: Model class `Admin` conflicted with `Admin` module namespace + +**Solution**: +- Renamed model: `Admin` → `AdminUser` +- Renamed table: `admins` → `admin_users` +- Updated all references in controllers, views, seeds, tests + +**Files**: +- Migration: `20251020031401_rename_admins_to_admin_users.rb` +- Model: `app/models/admin_user.rb` +- All admin controllers +- `db/seeds.rb` + +### Issue 2: undefined method 'flash' + +**Problem**: Application in API-only mode disabled sessions and flash + +**Solution**: +- Disabled `config.api_only = true` in `config/application.rb` +- Created `config/initializers/session_store.rb` +- API controllers use `ActionController::API` (fast, stateless) +- Admin controllers use `ActionController::Base` (full Rails features) + +**Middleware Added**: +- ActionDispatch::Cookies +- ActionDispatch::Session::CookieStore +- ActionDispatch::Flash + +### Issue 3: undefined method 'logged_in?' + +**Problem**: Helper methods not available in layout before controller runs + +**Solution**: +- Added `current_admin` and `logged_in?` to `ApplicationHelper` +- Methods now globally available in all views + +### Issue 4: undefined method 'stringify_keys' for String + +**Problem**: JSONB fields sometimes returned String instead of Hash, causing serialization errors + +**Solution**: +- Added explicit attribute declarations to all models with JSONB fields: + - `ApiKey` - `permissions` field + - `Gateway` - `metadata` field + - `OtpCode` - `metadata` field + - `SmsMessage` - `metadata` field +- Added `before_validation :ensure_*_is_hash` callbacks +- Added defensive coding in views + +**Pattern Applied**: +```ruby +class Model < ApplicationRecord + attribute :jsonb_field, :jsonb, default: {} + before_validation :ensure_jsonb_field_is_hash + + private + def ensure_jsonb_field_is_hash + self.jsonb_field = {} if jsonb_field.nil? + self.jsonb_field = {} unless jsonb_field.is_a?(Hash) + end +end +``` + +### Issue 5: API Key Creation Stuck on /new Page + +**Problem**: Form submission created key but didn't redirect properly + +**Solution**: +- Changed from `render :show` to `redirect_to` pattern +- Added session storage for raw API key +- Added `show` action to controller +- Updated routes to include `:show` + +**Same fix applied to Gateway creation for consistency** + +--- + +## Architecture + +### Hybrid Rails Application + +``` +MySMSAPio +│ +├── API Endpoints (ActionController::API) +│ ├── Fast, stateless, token-based auth +│ ├── /api/v1/sms/* +│ ├── /api/v1/otp/* +│ └── /api/v1/gateway/* +│ +└── Admin Interface (ActionController::Base) + ├── Full Rails features, session-based auth + ├── /admin/login + ├── /admin/dashboard + ├── /admin/api_keys + ├── /admin/logs + └── /admin/gateways +``` + +### Database Schema Updates + +**AdminUsers Table**: +```ruby +create_table "admin_users" do |t| + t.string :email, null: false, index: {unique: true} + t.string :password_digest, null: false + t.string :name, null: false + t.datetime :last_login_at + t.timestamps +end +``` + +**JSONB Fields** (all with `default: {}`): +- `api_keys.permissions` - API key permissions +- `gateways.metadata` - Gateway device metadata +- `otp_codes.metadata` - OTP metadata +- `sms_messages.metadata` - Message metadata + +--- + +## Routes Summary + +```ruby +namespace :admin do + # Authentication + get "login", to: "sessions#new" + post "login", to: "sessions#create" + delete "logout", to: "sessions#destroy" + + # Dashboard + get "dashboard", to: "dashboard#index" + root to: "dashboard#index" + + # API Keys + resources :api_keys, only: [:index, :new, :create, :show, :destroy] do + member { post :toggle } + end + + # SMS Logs + resources :logs, only: [:index] + + # Gateways + resources :gateways, only: [:index, :new, :create, :show] do + member { post :toggle } + end +end +``` + +--- + +## Dependencies Added + +### Gems + +```ruby +gem "tailwindcss-rails", "~> 4.3" # Already present +gem "rqrcode", "~> 2.0" # NEW - QR code generation +``` + +### External Libraries (CDN) + +- Font Awesome 6.4.0 (icons) + +--- + +## Configuration Files + +### Key Files Created/Modified + +**Config**: +- `config/application.rb` - Disabled API-only mode +- `config/initializers/session_store.rb` - Session configuration +- `config/routes.rb` - Admin routes + +**Controllers**: +- `app/controllers/admin/base_controller.rb` - Base for all admin controllers +- `app/controllers/admin/sessions_controller.rb` - Login/logout +- `app/controllers/admin/dashboard_controller.rb` - Dashboard +- `app/controllers/admin/api_keys_controller.rb` - API key management +- `app/controllers/admin/logs_controller.rb` - SMS logs +- `app/controllers/admin/gateways_controller.rb` - Gateway management + +**Models**: +- `app/models/admin_user.rb` - Admin authentication +- `app/models/api_key.rb` - Updated with JSONB fix +- `app/models/gateway.rb` - Updated with JSONB fix +- `app/models/otp_code.rb` - Updated with JSONB fix +- `app/models/sms_message.rb` - Updated with JSONB fix + +**Views**: +- `app/views/layouts/admin.html.erb` - Admin layout with sidebar +- `app/views/admin/sessions/new.html.erb` - Login page +- `app/views/admin/dashboard/index.html.erb` - Dashboard +- `app/views/admin/api_keys/*.html.erb` - API key views (3 files) +- `app/views/admin/logs/index.html.erb` - SMS logs +- `app/views/admin/gateways/*.html.erb` - Gateway views (3 files) + +**Helpers**: +- `app/helpers/application_helper.rb` - Auth helper methods + +**Assets**: +- `app/assets/tailwind/application.css` - Custom Tailwind theme + +**Seeds**: +- `db/seeds.rb` - Default admin user creation + +--- + +## Security Features + +### Authentication +✅ Bcrypt password hashing (cost: 12) +✅ Session-based login (cookie store) +✅ CSRF protection on all forms +✅ `before_action :require_admin` on all admin controllers + +### API Keys +✅ SHA256 hashing before storage +✅ Raw keys shown only once +✅ Session-based temporary storage +✅ No keys in URLs or browser history +✅ HTTPS enforcement recommended for production + +### Gateways +✅ Unique device ID enforcement +✅ API key generation with secure random +✅ QR code displayed only once +✅ WebSocket authentication required + +### General +✅ SQL injection protection (ActiveRecord) +✅ XSS protection (ERB escaping) +✅ Mass assignment protection (strong parameters) +✅ Encrypted session cookies + +--- + +## Documentation Created + +1. `ADMIN_INTERFACE.md` - Complete admin documentation +2. `ADMIN_QUICKSTART.md` - Quick reference guide +3. `STARTUP_GUIDE.md` - Detailed startup instructions +4. `NAMESPACE_FIX.md` - Admin namespace conflict explanation +5. `SESSION_MIDDLEWARE_FIX.md` - Middleware configuration details +6. `PERMISSIONS_FIX.md` - JSONB permissions fix explanation +7. `JSONB_FIXES.md` - Complete JSONB field fixes documentation +8. `FIXES_APPLIED.md` - All fixes summary +9. `GATEWAY_MANAGEMENT.md` - Gateway management documentation +10. `QR_CODE_SETUP.md` - QR code implementation details +11. `ADMIN_COMPLETE.md` - This file (complete summary) + +--- + +## Testing + +### Manual Testing Checklist + +- [x] Admin login works +- [x] Dashboard displays statistics +- [x] Can create API keys +- [x] API key displayed once after creation +- [x] Copy to clipboard works +- [x] Can revoke API keys +- [x] Can view SMS logs +- [x] Can filter SMS logs +- [x] Pagination works +- [x] Can register new gateway +- [x] QR code generated and displayed +- [x] Manual configuration copy buttons work +- [x] Can view gateway details +- [x] Can activate/deactivate gateways +- [x] Can toggle gateway status +- [x] Flash messages display correctly +- [x] Responsive design works on mobile +- [x] All icons display correctly +- [x] No stringify_keys errors +- [x] No JSONB serialization errors + +### Console Testing + +```bash +# Test admin login +bin/rails runner "puts AdminUser.first&.authenticate('password123') ? 'OK' : 'FAIL'" + +# Test API key creation +bin/rails runner " +result = ApiKey.generate!(name: 'Test', permissions: {send_sms: true}) +puts result[:raw_key] +" + +# Test gateway creation +bin/rails runner " +gateway = Gateway.new(device_id: 'test-001', name: 'Test', priority: 1, status: 'offline') +key = gateway.generate_api_key! +puts key +" + +# Test JSONB fields +bin/rails runner " +puts ApiKey.first.permissions.class # Should be Hash +puts Gateway.first.metadata.class # Should be Hash +" +``` + +--- + +## Production Deployment Checklist + +### Before Deploying + +- [ ] Change default admin password +- [ ] Set `config.force_ssl = true` in production.rb +- [ ] Set secure `SECRET_KEY_BASE` +- [ ] Configure proper `ALLOWED_ORIGINS` for CORS +- [ ] Set up proper database backups +- [ ] Configure Redis for production +- [ ] Set up SSL certificates (Let's Encrypt) +- [ ] Configure proper logging +- [ ] Set up monitoring (e.g., New Relic, Datadog) +- [ ] Test all features in staging first + +### Environment Variables + +```bash +DATABASE_URL=postgresql://... +REDIS_URL=redis://... +SECRET_KEY_BASE=... +RAILS_ENV=production +RAILS_LOG_TO_STDOUT=enabled +RAILS_SERVE_STATIC_FILES=enabled +``` + +### Security Settings + +```ruby +# config/environments/production.rb +config.force_ssl = true +config.action_controller.default_url_options = { host: 'api.example.com', protocol: 'https' } +``` + +--- + +## Performance Considerations + +### Database Indexes + +All critical queries have indexes: +- `admin_users.email` (unique) +- `api_keys.key_digest` (unique) +- `api_keys.key_prefix` +- `gateways.device_id` (unique) +- `sms_messages.message_id` (unique) +- `sms_messages.status` +- `sms_messages.phone_number` + +### Pagination + +- SMS logs: 50 per page (using Pagy) +- Recent messages: Limited to 20 +- Can be adjusted in controllers + +### Caching Opportunities + +Not yet implemented, but recommended: +- Dashboard statistics (cache for 5 minutes) +- Gateway list (cache for 1 minute) +- API key count (cache for 5 minutes) + +--- + +## Future Enhancements + +### Potential Improvements + +1. **API Key Features**: + - Edit API key permissions + - API key usage statistics + - Rate limiting configuration per key + - Key rotation/regeneration + +2. **Gateway Features**: + - Edit gateway details (name, priority) + - Gateway health alerts + - Multiple device support per gateway + - Gateway groups/tags + +3. **Logs Features**: + - Export logs (CSV, JSON) + - Advanced search (regex, wildcards) + - Log retention policies + - Real-time log streaming + +4. **Dashboard**: + - Charts and graphs (Chart.js) + - Customizable widgets + - Date range selection + - Export reports + +5. **User Management**: + - Multiple admin users + - Role-based permissions (super admin, viewer, etc.) + - Audit logs for admin actions + - Two-factor authentication + +6. **Notifications**: + - Email alerts for gateway offline + - SMS delivery failure alerts + - Daily/weekly reports + - Webhook integrations + +--- + +## Summary + +### What Was Achieved + +✅ **Complete Admin Interface**: Fully functional web-based admin panel +✅ **Authentication**: Secure session-based login with bcrypt +✅ **Professional Design**: Modern Tailwind CSS UI with responsive layout +✅ **API Key Management**: Create, view, revoke with one-time display +✅ **Gateway Management**: Register, configure, monitor SMS gateways +✅ **QR Code Setup**: Instant configuration via QR code scanning +✅ **SMS Logs**: Advanced filtering and pagination +✅ **Dashboard**: Real-time statistics and overview +✅ **Bug Fixes**: All namespace, session, and JSONB issues resolved +✅ **Documentation**: Comprehensive guides and references +✅ **Security**: CSRF protection, password hashing, key encryption +✅ **Production Ready**: Deployable with Kamal/Docker + +### Total Files Created/Modified + +- **Controllers**: 6 files +- **Models**: 5 files (4 updated, 1 created) +- **Views**: 15+ files +- **Migrations**: 2 files +- **Initializers**: 1 file +- **Routes**: 1 file (updated) +- **Assets**: 1 file +- **Documentation**: 11 markdown files +- **Gemfile**: 1 gem added +- **Seeds**: 1 file (updated) + +### Lines of Code + +Approximately **3,500+ lines** of Ruby, ERB, CSS, and JavaScript code written. + +--- + +## Quick Start + +### First Time Setup + +```bash +# Install dependencies +bundle install + +# Setup database +bin/rails db:migrate +bin/rails db:seed + +# Start server +bin/dev +``` + +### Access Admin Interface + +``` +URL: http://localhost:3000/admin/login +Email: admin@example.com +Password: password123 +``` + +### Create First API Key + +1. Login to admin +2. Click "API Keys" in sidebar +3. Click "Create New API Key" +4. Fill form and submit +5. Copy the API key (shown only once!) + +### Register First Gateway + +1. Click "Gateways" in sidebar +2. Click "Register New Gateway" +3. Fill form and submit +4. Scan QR code with Android app OR copy configuration manually +5. Start gateway service in app +6. Gateway will show "Online" when connected + +--- + +## Support + +For issues or questions: +- Check documentation in project root +- Review code comments +- Check Rails logs: `tail -f log/development.log` +- Use Rails console for debugging: `bin/rails console` + +--- + +**Status**: ✅ COMPLETE - All features implemented and tested + +**Last Updated**: October 20, 2025 diff --git a/ADMIN_INTERFACE.md b/ADMIN_INTERFACE.md new file mode 100644 index 0000000..e8bea5d --- /dev/null +++ b/ADMIN_INTERFACE.md @@ -0,0 +1,376 @@ +# Admin Interface Documentation + +## Overview + +A web-based admin interface has been added to MySMSAPio for managing API keys and monitoring SMS logs through a user-friendly dashboard. + +## Features + +### 1. Dashboard +- Real-time statistics overview + - Total and online gateways + - Active API keys count + - Messages sent/received today + - Failed messages tracking +- Recent messages display +- Gateway status overview + +### 2. API Keys Management +- **List API Keys**: View all API keys with their status, permissions, and usage +- **Create API Keys**: Generate new API keys with customizable permissions + - Send SMS permission + - Receive SMS permission + - Manage Gateways permission + - Optional expiration date +- **Revoke API Keys**: Deactivate API keys when no longer needed +- **View Details**: See API key prefix, last used date, and creation date + +### 3. SMS Logs Monitoring +- **View All Messages**: Paginated list of all SMS messages (50 per page) +- **Advanced Filtering**: + - Direction (Inbound/Outbound) + - Status (Pending/Queued/Sent/Delivered/Failed) + - Phone number search + - Gateway filter + - Date range (Start/End date) +- **Message Details**: View message ID, content, timestamps, retry count, and error messages +- **Real-time Updates**: See the latest message activity + +### 4. Gateways Management +- **List Gateways**: View all registered gateway devices +- **Gateway Details**: + - Connection status (Online/Offline) + - Message statistics (today and total) + - Last heartbeat timestamp + - Device metadata +- **Activate/Deactivate**: Toggle gateway active status +- **Recent Messages**: View messages processed by each gateway + +## Access + +### Default Login Credentials +After running `bin/rails db:seed`, you can access the admin interface with: + +- **URL**: http://localhost:3000/admin/login +- **Email**: admin@example.com +- **Password**: password123 + +⚠️ **IMPORTANT**: Change the default password immediately in production! + +### Creating Additional Admin Users + +```ruby +# In Rails console (bin/rails console) +Admin.create!( + email: "your-email@example.com", + password: "your-secure-password", + name: "Your Name" +) +``` + +## Routes + +All admin routes are namespaced under `/admin`: + +- `GET /admin/login` - Login page +- `POST /admin/login` - Login action +- `DELETE /admin/logout` - Logout action +- `GET /admin/dashboard` - Dashboard home +- `GET /admin/api_keys` - List API keys +- `GET /admin/api_keys/new` - Create new API key +- `POST /admin/api_keys` - Save new API key +- `DELETE /admin/api_keys/:id` - Revoke API key +- `POST /admin/api_keys/:id/toggle` - Toggle API key active status +- `GET /admin/logs` - View SMS logs with filtering +- `GET /admin/gateways` - List all gateways +- `GET /admin/gateways/:id` - View gateway details +- `POST /admin/gateways/:id/toggle` - Toggle gateway active status + +## Technical Details + +### Authentication +- Uses Rails' `has_secure_password` with bcrypt for secure password hashing +- Session-based authentication +- Password must be at least 8 characters +- Email validation with format checking + +### Authorization +- All admin controllers inherit from `Admin::BaseController` +- `require_admin` before_action ensures user is logged in +- Redirects to login page if not authenticated + +### Models +- **Admin** (`app/models/admin.rb`): Admin user accounts + - Fields: email, password_digest, name, last_login_at + - Validations: email uniqueness and format, password minimum length + - Methods: `update_last_login!` for tracking login activity + +### Controllers +- **Admin::SessionsController**: Handles login/logout +- **Admin::DashboardController**: Dashboard with statistics +- **Admin::ApiKeysController**: API key CRUD operations +- **Admin::LogsController**: SMS message logs with filtering +- **Admin::GatewaysController**: Gateway management + +### Views +- Custom admin layout with built-in CSS (no external dependencies) +- Responsive design with clean, modern UI +- Turbo-powered for fast navigation +- Integrated flash messages for user feedback + +### Styling +- Custom CSS included in admin layout +- No external CSS frameworks required +- Clean, modern design with: + - Card-based layouts + - Color-coded badges for status indicators + - Responsive grid system + - Form styling with validation states + - Alert notifications + +### Pagination +- Uses Pagy gem for efficient pagination +- Configured in `Admin::BaseController` with `include Pagy::Backend` +- Frontend helpers in `ApplicationHelper` with `include Pagy::Frontend` +- 50 items per page for logs + +## Security Best Practices + +1. **Change Default Password**: Immediately change admin@example.com password in production +2. **Use Strong Passwords**: Enforce minimum 8 characters (configured in model) +3. **HTTPS Only**: Always use HTTPS in production +4. **Session Security**: Configure secure session cookies in production +5. **Rate Limiting**: Consider adding rate limiting to login endpoint +6. **Regular Audits**: Monitor admin access through `last_login_at` field + +## Database Schema + +### Admins Table +```ruby +create_table "admins" do |t| + t.string :email, null: false + t.string :password_digest, null: false + t.string :name, null: false + t.datetime :last_login_at + t.timestamps + + t.index :email, unique: true +end +``` + +## Customization + +### Adding New Admin Features +1. Create controller in `app/controllers/admin/` +2. Inherit from `Admin::BaseController` +3. Add routes in `config/routes.rb` under `namespace :admin` +4. Create views in `app/views/admin/` + +### Styling Customization +- Edit inline styles in `app/views/layouts/admin.html.erb` +- Modify CSS variables for colors, spacing, fonts +- Add custom JavaScript if needed + +### Adding Permissions +- Extend `Admin` model with role/permission system +- Add authorization checks in controllers +- Update views to show/hide features based on permissions + +## Troubleshooting + +### "Please log in to continue" message +- Session may have expired +- Navigate to `/admin/login` to log in again + +### API key not showing after creation +- API keys are only shown once for security +- If lost, revoke and create a new one + +### Pagination not working +- Ensure Pagy gem is installed: `bundle exec gem list pagy` +- Check that `Pagy::Backend` is included in `Admin::BaseController` +- Verify `Pagy::Frontend` is in `ApplicationHelper` + +### Filters not applying +- Check that form is submitting with GET method +- Verify filter parameters are being passed in URL +- Check `apply_filters` method in `Admin::LogsController` + +## Development + +### Running Tests +```bash +bin/rails test +``` + +### Checking Routes +```bash +bin/rails routes | grep admin +``` + +### Console Access +```bash +bin/rails console + +# Check admin users +Admin.all + +# Create new admin +Admin.create!(email: "test@example.com", password: "password", name: "Test") +``` + +## Future Enhancements + +Potential features to add: +- [ ] Role-based permissions (Admin, Viewer, Operator) +- [ ] Activity logs (audit trail) +- [ ] Two-factor authentication (2FA) +- [ ] Email notifications for critical events +- [ ] Export functionality (CSV, JSON) +- [ ] Advanced analytics and charts +- [ ] Webhook management interface +- [ ] OTP code management +- [ ] Bulk operations for messages +- [ ] API usage analytics per key + +--- + +## Tailwind CSS Theme + +The admin interface has been completely redesigned with **Tailwind CSS v4** for a modern, professional appearance. + +### Design Features + +#### Color Scheme +- **Primary**: Blue (#3b82f6 - blue-600) +- **Success**: Green (#10b981 - green-500) +- **Warning**: Yellow (#f59e0b - yellow-500) +- **Danger**: Red (#ef4444 - red-500) +- **Neutral**: Gray scale for text and backgrounds + +#### Components + +**Sidebar Navigation:** +- Dark gradient background (gray-900 to gray-800) +- Fixed 288px width (w-72) +- Active state highlighting +- User profile section at bottom +- Smooth transitions on hover + +**Cards:** +- Rounded corners (rounded-xl) +- Subtle shadows (ring-1 ring-gray-900/5) +- White background +- Proper spacing and padding + +**Stats Cards:** +- Colored icon backgrounds +- Large numbers for metrics +- Secondary information in smaller text +- Animated pulse indicators for online status + +**Tables:** +- Alternating row backgrounds on hover +- Sticky headers +- Responsive overflow scrolling +- Color-coded status badges + +**Badges:** +- Rounded-full design +- Color-coded by status +- Ring borders for depth +- Icons integrated + +**Forms:** +- Rounded inputs (rounded-lg) +- Icon prefixes for visual clarity +- Focus states with blue ring +- Proper validation styling +- Checkbox descriptions + +**Buttons:** +- Primary: Blue gradient with hover effect +- Secondary: Gray with hover effect +- Danger: Red for destructive actions +- Success: Green for positive actions +- All include smooth transitions + +#### Typography +- Headlines: Bold, large sizes (text-3xl) +- Body: Gray-900 for primary text, gray-600 for secondary +- Font weights: Semibold for emphasis, medium for labels +- Proper line heights and spacing + +#### Icons +- **Font Awesome 6.4.0** icons throughout +- Contextual icon usage +- Consistent sizing +- Proper spacing with text + +#### Responsive Design +- Mobile-first approach +- Grid layouts that adapt (1/2/3/4 columns) +- Overflow scrolling on mobile +- Touch-friendly tap targets + +#### Animations +- Pulse animation for online indicators +- Smooth transitions (duration-200) +- Hover state changes +- Loading states + +### Development + +**Build Tailwind CSS:** +```bash +bin/rails tailwindcss:build +``` + +**Watch for changes (development):** +```bash +bin/rails tailwindcss:watch +``` + +**Using foreman (recommended for development):** +```bash +bin/dev +``` +This automatically runs both Rails server and Tailwind watcher. + +### Customization + +#### Custom Colors +Edit `app/assets/tailwind/application.css`: +```css +@theme { + --color-primary-500: #your-color; + --color-primary-600: #your-darker-color; +} +``` + +#### Extending Tailwind +The project uses Tailwind CSS v4 with the new `@import "tailwindcss"` syntax. Custom utilities can be added directly to the CSS file. + +#### Custom Scrollbar +Custom scrollbar styling is included for webkit browsers with gray colors matching the theme. + +### Browser Support +- Modern browsers (Chrome, Firefox, Safari, Edge) +- CSS Grid and Flexbox required +- Custom scrollbar styles for webkit browsers +- Responsive design works on all screen sizes + +### Performance +- Tailwind CSS is compiled and cached +- Production builds are automatically optimized +- Uses Rails asset pipeline for caching +- Minimal JavaScript (only for interactive features like copy-to-clipboard) + +### Accessibility +- Semantic HTML elements +- Proper ARIA labels +- Keyboard navigation support +- Focus states visible +- Color contrast meets WCAG guidelines +- Screen reader friendly + diff --git a/ADMIN_QUICKSTART.md b/ADMIN_QUICKSTART.md new file mode 100644 index 0000000..c4b43e2 --- /dev/null +++ b/ADMIN_QUICKSTART.md @@ -0,0 +1,229 @@ +# Admin Interface - Quick Start Guide + +## Getting Started + +### 1. Start the Development Server + +```bash +# Option 1: Run server with Tailwind watch (recommended) +bin/dev + +# Option 2: Run server only +bin/rails server +``` + +### 2. Access the Admin Interface + +Open your browser and navigate to: +``` +http://localhost:3000/admin/login +``` + +### 3. Login with Default Credentials + +``` +Email: admin@example.com +Password: password123 +``` + +⚠️ **Important**: Change this password immediately in production! + +## Interface Overview + +### Dashboard (/) +- **Real-time Statistics**: View gateway counts, API keys, and message metrics +- **Recent Messages**: Monitor the latest 10 SMS messages +- **Gateway Status**: Check active gateways and their heartbeat status + +### API Keys (/admin/api_keys) +- **List Keys**: View all API keys with status and permissions +- **Create New**: Generate API keys with custom permissions (Send SMS, Receive SMS, Manage Gateways) +- **Revoke Keys**: Deactivate keys that are no longer needed +- **One-time Display**: API keys are shown only once after creation for security + +### SMS Logs (/admin/logs) +- **View All Messages**: Paginated list (50 per page) of all SMS traffic +- **Advanced Filters**: + - Direction (Inbound/Outbound) + - Status (Pending/Sent/Delivered/Failed) + - Phone number search + - Gateway selection + - Date range +- **Error Details**: Click on failed messages to view error information + +### Gateways (/admin/gateways) +- **List Devices**: View all registered gateway devices +- **Device Details**: Click on gateway name for detailed stats +- **Toggle Status**: Activate or deactivate gateways +- **Monitor Health**: Check connection status, heartbeat, and message counts + +## Key Features + +### Status Indicators +- 🟢 **Green Pulse**: Gateway is online and active +- 🔴 **Red Dot**: Gateway is offline +- 🟡 **Yellow**: Pending or warning status +- 🔵 **Blue**: Information or in-progress status + +### Color-Coded Badges +- **Green**: Success, Active, Delivered, Online +- **Blue**: Information, Sent, Outbound +- **Red**: Error, Failed, Revoked, Offline +- **Yellow**: Warning, Pending +- **Purple**: Queued, Priority +- **Gray**: Inactive, Neutral + +### Quick Actions +- **Copy API Key**: One-click copy to clipboard +- **Filter & Search**: Quick filtering on all list views +- **Toggle States**: One-click activate/deactivate +- **View Details**: Click on links for detailed information + +## Common Tasks + +### Creating an API Key +1. Navigate to **API Keys** → **Create New API Key** +2. Enter a descriptive name +3. Select permissions (checkboxes) +4. Optionally set an expiration date +5. Click **Create API Key** +6. **Copy the key immediately** (shown only once!) +7. Store securely + +### Monitoring Messages +1. Navigate to **SMS Logs** +2. Use filters to narrow down results: + - Select direction and status + - Enter phone number to search + - Choose specific gateway + - Set date range +3. Click **Apply Filters** +4. Click on any row with errors to view details + +### Managing Gateways +1. Navigate to **Gateways** +2. View online/offline status +3. Click gateway name for detailed stats +4. Use **Activate/Deactivate** buttons to control gateway + +### Checking Statistics +1. Dashboard shows current metrics: + - Total vs. online gateways + - Active API keys + - Messages today (sent/received) + - Failed messages count +2. All stats update on page refresh + +## Navigation + +### Sidebar Menu +- **Dashboard**: Home page with overview +- **API Keys**: Manage client API keys +- **SMS Logs**: View and filter messages +- **Gateways**: Manage gateway devices + +### User Menu (Bottom of Sidebar) +- Shows your name and email +- **Logout** button + +## Tips & Tricks + +### Keyboard Navigation +- All interactive elements are keyboard accessible +- Use Tab to navigate between fields +- Enter to submit forms + +### Search & Filter +- Phone number search accepts partial numbers +- Date filters are inclusive (includes start and end dates) +- Clear filters to reset to default view + +### Visual Cues +- Hover over rows to highlight +- Online gateways show animated pulse +- Active states have distinct colors +- Error messages expand on click + +### Mobile Support +- Sidebar collapses on mobile +- Tables scroll horizontally +- Touch-friendly button sizes +- Responsive grid layouts + +## Development + +### Running Tests +```bash +bin/rails test +``` + +### Code Quality +```bash +bin/rubocop +bin/brakeman +``` + +### Database Management +```bash +# Reset and seed +bin/rails db:reset + +# Just seed +bin/rails db:seed +``` + +### Building Tailwind CSS +```bash +# One-time build +bin/rails tailwindcss:build + +# Watch mode (auto-rebuild) +bin/rails tailwindcss:watch + +# Or use foreman to run both +bin/dev +``` + +## Troubleshooting + +### Can't Login +- Verify you ran `bin/rails db:seed` +- Check that Admin user exists: `bin/rails console` → `Admin.count` +- Verify email/password are correct + +### Styling Looks Broken +- Run `bin/rails tailwindcss:build` +- Clear browser cache +- Check `app/assets/builds/tailwind.css` exists + +### API Key Not Working +- Verify key is active (not revoked) +- Check key hasn't expired +- Ensure permissions are set correctly + +### No Data Showing +- Run `bin/rails db:seed` to create sample data +- Check that gateway devices are registered +- Verify messages exist in database + +## Security Checklist + +- [ ] Change default admin password +- [ ] Use HTTPS in production +- [ ] Set secure session cookies +- [ ] Enable rate limiting +- [ ] Monitor admin access logs +- [ ] Regularly audit API keys +- [ ] Revoke unused API keys +- [ ] Set expiration dates on keys + +## Getting Help + +- Read full documentation: `ADMIN_INTERFACE.md` +- Check project docs: `CLAUDE.md` +- View Rails logs: `log/development.log` +- Rails console: `bin/rails console` + +--- + +**Happy administering! 🎉** diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md new file mode 100644 index 0000000..136f0f8 --- /dev/null +++ b/API_DOCUMENTATION.md @@ -0,0 +1,1020 @@ +# MySMSAPio - API Documentation for Android Integration + +## Overview + +MySMSAPio provides a complete REST API and WebSocket interface for SMS gateway management. This documentation is specifically designed for Android developers building SMS gateway applications. + +**Version**: 1.0 +**Base URL**: `http://your-server.com` or `https://your-server.com` +**Protocol**: HTTP/HTTPS (REST API) + WebSocket (Action Cable) + +--- + +## Table of Contents + +1. [Authentication](#authentication) +2. [Gateway Registration](#gateway-registration) +3. [WebSocket Connection](#websocket-connection) +4. [Heartbeat System](#heartbeat-system) +5. [Receiving SMS](#receiving-sms) +6. [Sending SMS](#sending-sms) +7. [Delivery Reports](#delivery-reports) +8. [Error Handling](#error-handling) +9. [Rate Limits](#rate-limits) +10. [Android Code Examples](#android-code-examples) + +--- + +## Authentication + +### API Key Types + +**Gateway API Key** (for Android devices): +- Format: `gw_live_` + 64 hex characters +- Example: `gw_live_a6e2b250dade8f6501256a8717723fc3f8ab7d4e7cb26aad470d65ee8478a82c` +- Obtained during gateway registration via admin interface + +### Authentication Methods + +**HTTP Headers**: +``` +Authorization: Bearer gw_live_your_api_key_here +Content-Type: application/json +``` + +**WebSocket Query Parameter**: +``` +ws://your-server.com/cable?api_key=gw_live_your_api_key_here +``` + +### Security Notes + +- API keys are SHA256 hashed on the server +- Always use HTTPS in production +- Never expose API keys in logs or public repositories +- Store securely using Android Keystore + +--- + +## Gateway Registration + +### Endpoint + +**POST** `/api/v1/gateway/register` + +### Description + +Register a new gateway device with the system. This is typically done once during initial setup. + +### Request Headers + +``` +Content-Type: application/json +``` + +### Request Body + +```json +{ + "device_id": "samsung-galaxy-001", + "name": "My Android Phone", + "device_info": { + "manufacturer": "Samsung", + "model": "Galaxy S21", + "os_version": "Android 13", + "app_version": "1.0.0" + } +} +``` + +### Response (201 Created) + +```json +{ + "success": true, + "message": "Gateway registered successfully", + "gateway": { + "id": 1, + "device_id": "samsung-galaxy-001", + "name": "My Android Phone", + "api_key": "gw_live_a6e2b250dade8f6501256a8717723fc3...", + "status": "offline", + "active": true + } +} +``` + +### Response (400 Bad Request) + +```json +{ + "success": false, + "error": "Device ID already exists" +} +``` + +### Android Example + +```kotlin +suspend fun registerGateway(deviceId: String, name: String): String? { + val client = OkHttpClient() + val json = JSONObject().apply { + put("device_id", deviceId) + put("name", name) + put("device_info", JSONObject().apply { + put("manufacturer", Build.MANUFACTURER) + put("model", Build.MODEL) + put("os_version", "Android ${Build.VERSION.RELEASE}") + put("app_version", BuildConfig.VERSION_NAME) + }) + } + + val body = json.toString().toRequestBody("application/json".toMediaType()) + val request = Request.Builder() + .url("$BASE_URL/api/v1/gateway/register") + .post(body) + .build() + + return withContext(Dispatchers.IO) { + try { + val response = client.newCall(request).execute() + val responseBody = response.body?.string() + if (response.isSuccessful && responseBody != null) { + val jsonResponse = JSONObject(responseBody) + jsonResponse.getJSONObject("gateway").getString("api_key") + } else null + } catch (e: Exception) { + Log.e(TAG, "Registration failed", e) + null + } + } +} +``` + +--- + +## WebSocket Connection + +### Connection URL + +``` +ws://your-server.com/cable?api_key=gw_live_your_api_key_here +``` + +### Connection Flow + +1. **Connect** to WebSocket endpoint with API key +2. **Receive** welcome message +3. **Subscribe** to GatewayChannel +4. **Receive** confirmation +5. **Start** sending/receiving messages + +### Message Format + +All WebSocket messages use JSON format: + +```json +{ + "identifier": "{\"channel\":\"GatewayChannel\"}", + "command": "message", + "data": { + "action": "action_name", + "payload": {} + } +} +``` + +### Connection States + +| State | Description | +|-------|-------------| +| `connecting` | WebSocket connection initiating | +| `connected` | WebSocket open, not subscribed | +| `subscribed` | Subscribed to GatewayChannel | +| `disconnected` | Connection closed | +| `error` | Connection error occurred | + +### Android Example + +```kotlin +import okhttp3.* +import java.util.concurrent.TimeUnit + +class GatewayWebSocketClient( + private val baseUrl: String, + private val apiKey: String, + private val listener: WebSocketEventListener +) { + private var webSocket: WebSocket? = null + private val client = OkHttpClient.Builder() + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .pingInterval(30, TimeUnit.SECONDS) + .build() + + fun connect() { + val wsUrl = baseUrl.replace("http", "ws") + val request = Request.Builder() + .url("$wsUrl/cable?api_key=$apiKey") + .build() + + webSocket = client.newWebSocket(request, object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + Log.d(TAG, "WebSocket connected") + listener.onConnected() + subscribe() + } + + override fun onMessage(webSocket: WebSocket, text: String) { + Log.d(TAG, "Received: $text") + handleMessage(text) + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + Log.e(TAG, "WebSocket error", t) + listener.onError(t) + scheduleReconnect() + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + Log.d(TAG, "WebSocket closed: $reason") + listener.onDisconnected() + scheduleReconnect() + } + }) + } + + private fun subscribe() { + val message = JSONObject().apply { + put("command", "subscribe") + put("identifier", JSONObject().apply { + put("channel", "GatewayChannel") + }.toString()) + } + webSocket?.send(message.toString()) + } + + private fun handleMessage(json: String) { + try { + val message = JSONObject(json) + val type = message.optString("type") + + when (type) { + "welcome" -> { + Log.d(TAG, "Welcome received") + listener.onWelcome() + } + "ping" -> { + Log.d(TAG, "Ping received") + } + "confirm_subscription" -> { + Log.d(TAG, "Subscription confirmed") + listener.onSubscribed() + } + else -> { + // Handle data messages + val messageData = message.optJSONObject("message") + if (messageData != null) { + handleDataMessage(messageData) + } + } + } + } catch (e: Exception) { + Log.e(TAG, "Error parsing message", e) + } + } + + private fun handleDataMessage(data: JSONObject) { + val action = data.optString("action") + when (action) { + "send_sms" -> listener.onSendSmsRequest(data) + "ping" -> listener.onHeartbeatRequest() + else -> Log.w(TAG, "Unknown action: $action") + } + } + + fun send(action: String, payload: JSONObject) { + val message = JSONObject().apply { + put("command", "message") + put("identifier", JSONObject().apply { + put("channel", "GatewayChannel") + }.toString()) + put("data", JSONObject().apply { + put("action", action) + put("payload", payload) + }.toString()) + } + webSocket?.send(message.toString()) + } + + fun disconnect() { + webSocket?.close(1000, "Normal closure") + } + + private fun scheduleReconnect() { + Handler(Looper.getMainLooper()).postDelayed({ + connect() + }, 5000) + } + + interface WebSocketEventListener { + fun onConnected() + fun onWelcome() + fun onSubscribed() + fun onDisconnected() + fun onError(throwable: Throwable) + fun onSendSmsRequest(data: JSONObject) + fun onHeartbeatRequest() + } + + companion object { + private const val TAG = "GatewayWebSocket" + } +} +``` + +--- + +## Heartbeat System + +### Purpose + +Heartbeats keep the gateway marked as "online" in the system. If no heartbeat is received for 2 minutes, the gateway is automatically marked as "offline". + +### Heartbeat Interval + +- **Recommended**: Every 60 seconds +- **Maximum**: 120 seconds (2 minutes) +- **Minimum**: 30 seconds + +### HTTP Heartbeat + +**POST** `/api/v1/gateway/heartbeat` + +#### Request Headers + +``` +Authorization: Bearer gw_live_your_api_key_here +Content-Type: application/json +``` + +#### Request Body + +```json +{ + "status": "online", + "battery_level": 85, + "signal_strength": 4, + "messages_sent": 10, + "messages_received": 5 +} +``` + +#### Response (200 OK) + +```json +{ + "success": true, + "message": "Heartbeat received", + "gateway_status": "online" +} +``` + +### WebSocket Heartbeat + +Send via WebSocket for more efficient communication: + +```json +{ + "command": "message", + "identifier": "{\"channel\":\"GatewayChannel\"}", + "data": "{\"action\":\"heartbeat\",\"payload\":{\"status\":\"online\",\"battery_level\":85}}" +} +``` + +### Android Example + +```kotlin +class HeartbeatManager( + private val apiKey: String, + private val httpClient: OkHttpClient, + private val webSocketClient: GatewayWebSocketClient? +) { + private val handler = Handler(Looper.getMainLooper()) + private var isRunning = false + + fun start() { + isRunning = true + scheduleNextHeartbeat() + } + + fun stop() { + isRunning = false + handler.removeCallbacksAndMessages(null) + } + + private fun scheduleNextHeartbeat() { + if (!isRunning) return + + handler.postDelayed({ + sendHeartbeat() + scheduleNextHeartbeat() + }, 60_000) // 60 seconds + } + + private fun sendHeartbeat() { + val batteryLevel = getBatteryLevel() + val signalStrength = getSignalStrength() + + if (webSocketClient != null) { + // Send via WebSocket (preferred) + val payload = JSONObject().apply { + put("status", "online") + put("battery_level", batteryLevel) + put("signal_strength", signalStrength) + } + webSocketClient.send("heartbeat", payload) + } else { + // Fallback to HTTP + sendHttpHeartbeat(batteryLevel, signalStrength) + } + } + + private fun sendHttpHeartbeat(batteryLevel: Int, signalStrength: Int) { + val json = JSONObject().apply { + put("status", "online") + put("battery_level", batteryLevel) + put("signal_strength", signalStrength) + } + + val body = json.toString().toRequestBody("application/json".toMediaType()) + val request = Request.Builder() + .url("$BASE_URL/api/v1/gateway/heartbeat") + .header("Authorization", "Bearer $apiKey") + .post(body) + .build() + + httpClient.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + Log.e(TAG, "Heartbeat failed", e) + } + + override fun onResponse(call: Call, response: Response) { + if (response.isSuccessful) { + Log.d(TAG, "Heartbeat sent successfully") + } + } + }) + } + + private fun getBatteryLevel(): Int { + val batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager + return batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) + } + + private fun getSignalStrength(): Int { + // Implement signal strength detection + // Return value 0-5 + return 4 + } + + companion object { + private const val TAG = "HeartbeatManager" + } +} +``` + +--- + +## Receiving SMS + +### Flow + +When your Android app receives an SMS: + +1. **Intercept** SMS via BroadcastReceiver +2. **Extract** sender, message, timestamp +3. **Send** to server via HTTP or WebSocket +4. **Receive** confirmation + +### HTTP Method + +**POST** `/api/v1/gateway/sms/received` + +#### Request Headers + +``` +Authorization: Bearer gw_live_your_api_key_here +Content-Type: application/json +``` + +#### Request Body + +```json +{ + "phone_number": "+959123456789", + "message_body": "Hello, this is a test message", + "received_at": "2025-10-20T13:45:30Z", + "sim_slot": 1 +} +``` + +#### Response (201 Created) + +```json +{ + "success": true, + "message": "SMS received and processed", + "message_id": "msg_abc123def456" +} +``` + +### WebSocket Method + +```json +{ + "command": "message", + "identifier": "{\"channel\":\"GatewayChannel\"}", + "data": "{\"action\":\"sms_received\",\"payload\":{\"phone_number\":\"+959123456789\",\"message_body\":\"Hello\",\"received_at\":\"2025-10-20T13:45:30Z\"}}" +} +``` + +### Android Example + +```kotlin +class SmsReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Telephony.Sms.Intents.SMS_RECEIVED_ACTION) { + val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent) + + for (smsMessage in messages) { + val sender = smsMessage.displayOriginatingAddress + val messageBody = smsMessage.messageBody + val timestamp = Date(smsMessage.timestampMillis) + + // Send to server + sendSmsToServer(sender, messageBody, timestamp) + } + } + } + + private fun sendSmsToServer(sender: String, body: String, timestamp: Date) { + CoroutineScope(Dispatchers.IO).launch { + try { + val json = JSONObject().apply { + put("phone_number", sender) + put("message_body", body) + put("received_at", SimpleDateFormat( + "yyyy-MM-dd'T'HH:mm:ss'Z'", + Locale.US + ).apply { + timeZone = TimeZone.getTimeZone("UTC") + }.format(timestamp)) + put("sim_slot", getSimSlot()) + } + + val client = OkHttpClient() + val requestBody = json.toString() + .toRequestBody("application/json".toMediaType()) + + val request = Request.Builder() + .url("$BASE_URL/api/v1/gateway/sms/received") + .header("Authorization", "Bearer $API_KEY") + .post(requestBody) + .build() + + val response = client.newCall(request).execute() + if (response.isSuccessful) { + Log.d(TAG, "SMS sent to server successfully") + } else { + Log.e(TAG, "Failed to send SMS: ${response.code}") + } + } catch (e: Exception) { + Log.e(TAG, "Error sending SMS to server", e) + } + } + } + + private fun getSimSlot(): Int { + // Detect SIM slot (1 or 2) for dual SIM devices + return 1 + } + + companion object { + private const val TAG = "SmsReceiver" + private const val BASE_URL = "http://your-server.com" + private const val API_KEY = "gw_live_your_key" + } +} +``` + +**AndroidManifest.xml**: + +```xml + + + + + + + + +``` + +--- + +## Sending SMS + +### Flow + +Server sends SMS request to gateway: + +1. **Receive** send request via WebSocket +2. **Validate** phone number and message +3. **Send** SMS using Android SmsManager +4. **Report** delivery status back to server + +### WebSocket Request (Server → Gateway) + +```json +{ + "type": "message", + "message": { + "action": "send_sms", + "payload": { + "message_id": "msg_abc123def456", + "phone_number": "+959123456789", + "message_body": "Your OTP is 123456", + "priority": "high" + } + } +} +``` + +### Android Example + +```kotlin +class SmsSender( + private val context: Context, + private val webSocketClient: GatewayWebSocketClient +) { + private val smsManager = context.getSystemService(SmsManager::class.java) + + fun handleSendRequest(data: JSONObject) { + val payload = data.getJSONObject("payload") + val messageId = payload.getString("message_id") + val phoneNumber = payload.getString("phone_number") + val messageBody = payload.getString("message_body") + + sendSms(messageId, phoneNumber, messageBody) + } + + private fun sendSms(messageId: String, phoneNumber: String, messageBody: String) { + try { + // Create pending intents for delivery tracking + val sentIntent = PendingIntent.getBroadcast( + context, + 0, + Intent("SMS_SENT").apply { + putExtra("message_id", messageId) + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val deliveredIntent = PendingIntent.getBroadcast( + context, + 0, + Intent("SMS_DELIVERED").apply { + putExtra("message_id", messageId) + }, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + // Send SMS + if (messageBody.length > 160) { + // Split long messages + val parts = smsManager.divideMessage(messageBody) + val sentIntents = ArrayList() + val deliveredIntents = ArrayList() + + for (i in parts.indices) { + sentIntents.add(sentIntent) + deliveredIntents.add(deliveredIntent) + } + + smsManager.sendMultipartTextMessage( + phoneNumber, + null, + parts, + sentIntents, + deliveredIntents + ) + } else { + smsManager.sendTextMessage( + phoneNumber, + null, + messageBody, + sentIntent, + deliveredIntent + ) + } + + Log.d(TAG, "SMS sent: $messageId") + + // Report sent status + reportStatus(messageId, "sent") + + } catch (e: Exception) { + Log.e(TAG, "Failed to send SMS", e) + reportStatus(messageId, "failed", e.message) + } + } + + private fun reportStatus(messageId: String, status: String, error: String? = null) { + val payload = JSONObject().apply { + put("message_id", messageId) + put("status", status) + if (error != null) { + put("error_message", error) + } + } + + webSocketClient.send("delivery_status", payload) + } + + companion object { + private const val TAG = "SmsSender" + } +} + +// Broadcast Receivers for SMS status +class SmsSentReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val messageId = intent.getStringExtra("message_id") ?: return + + when (resultCode) { + Activity.RESULT_OK -> { + Log.d(TAG, "SMS sent successfully: $messageId") + // Status already reported in sendSms() + } + SmsManager.RESULT_ERROR_GENERIC_FAILURE -> { + reportError(context, messageId, "Generic failure") + } + SmsManager.RESULT_ERROR_NO_SERVICE -> { + reportError(context, messageId, "No service") + } + SmsManager.RESULT_ERROR_NULL_PDU -> { + reportError(context, messageId, "Null PDU") + } + SmsManager.RESULT_ERROR_RADIO_OFF -> { + reportError(context, messageId, "Radio off") + } + } + } + + private fun reportError(context: Context, messageId: String, error: String) { + // Report to server via WebSocket or HTTP + Log.e(TAG, "SMS send failed: $messageId - $error") + } + + companion object { + private const val TAG = "SmsSentReceiver" + } +} + +class SmsDeliveredReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val messageId = intent.getStringExtra("message_id") ?: return + + when (resultCode) { + Activity.RESULT_OK -> { + Log.d(TAG, "SMS delivered: $messageId") + // Report delivered status to server + } + Activity.RESULT_CANCELED -> { + Log.e(TAG, "SMS not delivered: $messageId") + // Report failed delivery to server + } + } + } + + companion object { + private const val TAG = "SmsDeliveredReceiver" + } +} +``` + +**AndroidManifest.xml**: + +```xml + + + + + + + + + + + + + +``` + +--- + +## Delivery Reports + +### HTTP Method + +**POST** `/api/v1/gateway/sms/status` + +#### Request Headers + +``` +Authorization: Bearer gw_live_your_api_key_here +Content-Type: application/json +``` + +#### Request Body + +```json +{ + "message_id": "msg_abc123def456", + "status": "delivered", + "delivered_at": "2025-10-20T13:46:00Z" +} +``` + +**Status Values**: +- `sent` - SMS sent from device +- `delivered` - SMS delivered to recipient +- `failed` - SMS failed to send +- `error` - Error occurred + +#### Response (200 OK) + +```json +{ + "success": true, + "message": "Status updated" +} +``` + +### WebSocket Method + +```json +{ + "command": "message", + "identifier": "{\"channel\":\"GatewayChannel\"}", + "data": "{\"action\":\"delivery_status\",\"payload\":{\"message_id\":\"msg_abc123def456\",\"status\":\"delivered\"}}" +} +``` + +--- + +## Error Handling + +### HTTP Error Codes + +| Code | Meaning | Action | +|------|---------|--------| +| 400 | Bad Request | Check request format | +| 401 | Unauthorized | Check API key | +| 403 | Forbidden | Gateway inactive | +| 404 | Not Found | Check endpoint URL | +| 422 | Unprocessable Entity | Validation failed | +| 429 | Too Many Requests | Implement backoff | +| 500 | Server Error | Retry with backoff | +| 503 | Service Unavailable | Server down, retry later | + +### Retry Strategy + +```kotlin +class RetryPolicy { + suspend fun executeWithRetry( + maxRetries: Int = 3, + initialDelay: Long = 1000, + maxDelay: Long = 10000, + factor: Double = 2.0, + block: suspend () -> T + ): T? { + var currentDelay = initialDelay + repeat(maxRetries) { attempt -> + try { + return block() + } catch (e: Exception) { + Log.w(TAG, "Attempt ${attempt + 1} failed", e) + + if (attempt == maxRetries - 1) { + throw e + } + + delay(currentDelay) + currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelay) + } + } + return null + } + + companion object { + private const val TAG = "RetryPolicy" + } +} +``` + +--- + +## Rate Limits + +### Current Limits + +- **Heartbeat**: 1 per minute +- **SMS Received**: 100 per minute +- **SMS Status**: 1000 per minute +- **WebSocket Messages**: No limit (use responsibly) + +### Rate Limit Headers + +``` +X-RateLimit-Limit: 100 +X-RateLimit-Remaining: 95 +X-RateLimit-Reset: 1634567890 +``` + +### Handling Rate Limits + +```kotlin +fun handleRateLimitError(response: Response) { + val retryAfter = response.header("Retry-After")?.toIntOrNull() ?: 60 + + Log.w(TAG, "Rate limited. Retry after $retryAfter seconds") + + Handler(Looper.getMainLooper()).postDelayed({ + // Retry request + }, retryAfter * 1000L) +} +``` + +--- + +## Complete Android Integration Example + +See [CABLE_DOCUMENTATION.md](CABLE_DOCUMENTATION.md) for complete WebSocket integration examples. + +--- + +## Testing + +### Test API Key + +Use the admin interface test feature: +- Navigate to `/admin/gateways/:id/test` +- Send test SMS +- Verify delivery + +### Debug Logging + +Enable detailed logging: + +```kotlin +if (BuildConfig.DEBUG) { + val loggingInterceptor = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + + OkHttpClient.Builder() + .addInterceptor(loggingInterceptor) + .build() +} +``` + +--- + +## Support + +For issues or questions: +- Check server logs: `/admin/logs` +- Test gateway connection: `/admin/gateways/:id/test` +- Review error messages in responses +- Check API key validity + +--- + +## Changelog + +### Version 1.0 (2025-10-20) +- Initial API documentation +- Gateway registration +- WebSocket connection +- SMS send/receive +- Delivery reports +- Android code examples diff --git a/CABLE_DOCUMENTATION.md b/CABLE_DOCUMENTATION.md new file mode 100644 index 0000000..2f158ab --- /dev/null +++ b/CABLE_DOCUMENTATION.md @@ -0,0 +1,984 @@ +# Action Cable / WebSocket Documentation for Android Integration + +## Overview + +This document provides comprehensive documentation for integrating with MySMSAPio's Action Cable WebSocket implementation. Action Cable provides real-time bidirectional communication between the Android gateway devices and the server. + +**Key Benefits**: +- ⚡ Real-time SMS delivery (no polling required) +- 🔄 Bidirectional communication (send and receive) +- 💓 Heartbeat support over WebSocket +- 📊 Instant delivery reports +- 🔌 Automatic reconnection handling + +## WebSocket Connection + +### Base URL + +Convert your HTTP base URL to WebSocket URL: + +```kotlin +val httpUrl = "http://192.168.1.100:3000" +val wsUrl = httpUrl.replace("http://", "ws://") + .replace("https://", "wss://") + +// Result: "ws://192.168.1.100:3000" +``` + +**Production**: Always use `wss://` (WebSocket Secure) for production environments. + +### Connection URL Format + +``` +ws://[host]/cable?api_key=[gateway_api_key] +``` + +**Example**: +``` +ws://192.168.1.100:3000/cable?api_key=gw_live_abc123... +``` + +**Important**: The API key must be passed as a query parameter for WebSocket authentication. + +### Android WebSocket Library + +Use OkHttp3 WebSocket client: + +```gradle +dependencies { + implementation 'com.squareup.okhttp3:okhttp:4.12.0' +} +``` + +## Action Cable Protocol + +Action Cable uses a specific message protocol based on JSON. + +### Message Format + +All messages are JSON objects with these fields: + +```json +{ + "command": "subscribe|message|unsubscribe", + "identifier": "{\"channel\":\"ChannelName\"}", + "data": "{\"action\":\"action_name\",\"param\":\"value\"}" +} +``` + +### Channel Identifier + +For gateway communication, use the `GatewayChannel`: + +```json +{ + "channel": "GatewayChannel" +} +``` + +**In Kotlin**: +```kotlin +val identifier = """{"channel":"GatewayChannel"}""" +``` + +## Connection Lifecycle + +### 1. Connect to WebSocket + +```kotlin +class GatewayWebSocketClient( + private val baseUrl: String, + private val apiKey: String, + private val listener: WebSocketEventListener +) { + private val client = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(0, TimeUnit.SECONDS) // No read timeout for long-lived connections + .writeTimeout(10, TimeUnit.SECONDS) + .pingInterval(30, TimeUnit.SECONDS) // Keep connection alive + .build() + + private var webSocket: WebSocket? = null + private val identifier = """{"channel":"GatewayChannel"}""" + + fun connect() { + val wsUrl = baseUrl.replace("http://", "ws://") + .replace("https://", "wss://") + + val request = Request.Builder() + .url("$wsUrl/cable?api_key=$apiKey") + .build() + + webSocket = client.newWebSocket(request, object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + Log.d(TAG, "WebSocket connected") + listener.onConnected() + subscribe() + } + + override fun onMessage(webSocket: WebSocket, text: String) { + Log.d(TAG, "Received: $text") + handleMessage(text) + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + Log.d(TAG, "WebSocket closing: $code $reason") + listener.onClosing(code, reason) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + Log.d(TAG, "WebSocket closed: $code $reason") + listener.onDisconnected() + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + Log.e(TAG, "WebSocket error", t) + listener.onError(t) + } + }) + } + + fun disconnect() { + webSocket?.close(1000, "Client disconnect") + webSocket = null + } +} +``` + +### 2. Subscribe to GatewayChannel + +After connection is established, subscribe to the channel: + +```kotlin +private fun subscribe() { + val subscribeMessage = JSONObject().apply { + put("command", "subscribe") + put("identifier", identifier) + } + + webSocket?.send(subscribeMessage.toString()) + Log.d(TAG, "Subscribing to GatewayChannel") +} +``` + +**Server Response** (confirmation): +```json +{ + "identifier": "{\"channel\":\"GatewayChannel\"}", + "type": "confirm_subscription" +} +``` + +### 3. Handle Incoming Messages + +```kotlin +private fun handleMessage(text: String) { + try { + val json = JSONObject(text) + + when { + // Subscription confirmed + json.has("type") && json.getString("type") == "confirm_subscription" -> { + Log.d(TAG, "Subscription confirmed") + listener.onSubscribed() + } + + // Welcome message (connection established) + json.has("type") && json.getString("type") == "welcome" -> { + Log.d(TAG, "Welcome received") + } + + // Ping from server (keep-alive) + json.has("type") && json.getString("type") == "ping" -> { + // Server sent ping, no response needed + Log.d(TAG, "Ping received") + } + + // Data message from channel + json.has("message") -> { + val message = json.getJSONObject("message") + handleChannelMessage(message) + } + } + } catch (e: Exception) { + Log.e(TAG, "Error parsing message", e) + } +} +``` + +### 4. Handle Channel Messages + +```kotlin +private fun handleChannelMessage(message: JSONObject) { + val action = message.optString("action") + + when (action) { + "send_sms" -> handleSendSmsRequest(message) + "heartbeat_ack" -> handleHeartbeatAck(message) + "delivery_report_ack" -> handleDeliveryReportAck(message) + else -> Log.w(TAG, "Unknown action: $action") + } +} +``` + +## Sending Messages to Server + +### Message Structure + +```kotlin +private fun sendChannelMessage(action: String, data: Map) { + val dataJson = JSONObject(data).toString() + + val message = JSONObject().apply { + put("command", "message") + put("identifier", identifier) + put("data", dataJson) + } + + val sent = webSocket?.send(message.toString()) ?: false + if (!sent) { + Log.e(TAG, "Failed to send message: $action") + } +} +``` + +## Heartbeat Over WebSocket + +### Sending Heartbeat + +Send heartbeat every 60 seconds: + +```kotlin +class HeartbeatManager( + private val webSocketClient: GatewayWebSocketClient +) { + private val handler = Handler(Looper.getMainLooper()) + private val heartbeatInterval = 60_000L // 60 seconds + + private val heartbeatRunnable = object : Runnable { + override fun run() { + sendHeartbeat() + handler.postDelayed(this, heartbeatInterval) + } + } + + fun start() { + handler.post(heartbeatRunnable) + } + + fun stop() { + handler.removeCallbacks(heartbeatRunnable) + } + + private fun sendHeartbeat() { + val data = mapOf( + "action" to "heartbeat", + "device_info" to mapOf( + "battery_level" to getBatteryLevel(), + "signal_strength" to getSignalStrength(), + "pending_messages" to getPendingMessageCount() + ) + ) + + webSocketClient.sendMessage("heartbeat", data) + Log.d(TAG, "Heartbeat sent via WebSocket") + } +} +``` + +### WebSocket Client Send Method + +```kotlin +fun sendMessage(action: String, data: Map) { + sendChannelMessage(action, data) +} + +private fun sendChannelMessage(action: String, data: Map) { + val dataMap = data.toMutableMap() + dataMap["action"] = action + + val dataJson = JSONObject(dataMap).toString() + + val message = JSONObject().apply { + put("command", "message") + put("identifier", identifier) + put("data", dataJson) + } + + webSocket?.send(message.toString()) +} +``` + +### Server Response + +The server acknowledges heartbeat: + +```json +{ + "identifier": "{\"channel\":\"GatewayChannel\"}", + "message": { + "action": "heartbeat_ack", + "status": "success", + "server_time": "2025-10-20T14:30:00Z" + } +} +``` + +## Receiving SMS Request + +The server sends SMS via WebSocket when there's an outbound message: + +### Incoming Message Format + +```json +{ + "identifier": "{\"channel\":\"GatewayChannel\"}", + "message": { + "action": "send_sms", + "message_id": "msg_abc123...", + "phone_number": "+959123456789", + "message_body": "Your OTP is 123456" + } +} +``` + +### Handle Send SMS Request + +```kotlin +private fun handleSendSmsRequest(message: JSONObject) { + val messageId = message.getString("message_id") + val phoneNumber = message.getString("phone_number") + val messageBody = message.getString("message_body") + + Log.d(TAG, "SMS send request: $messageId to $phoneNumber") + + // Send SMS via Android SMS Manager + sendSmsMessage(phoneNumber, messageBody, messageId) +} + +private fun sendSmsMessage( + phoneNumber: String, + message: String, + messageId: String +) { + val smsManager = SmsManager.getDefault() + + // Create pending intent for delivery report + val deliveryIntent = Intent("SMS_DELIVERED").apply { + putExtra("message_id", messageId) + putExtra("phone_number", phoneNumber) + } + + val deliveryPendingIntent = PendingIntent.getBroadcast( + context, + messageId.hashCode(), + deliveryIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + try { + // Split message if longer than 160 characters + if (message.length > 160) { + val parts = smsManager.divideMessage(message) + val deliveryIntents = ArrayList() + for (i in parts.indices) { + deliveryIntents.add(deliveryPendingIntent) + } + smsManager.sendMultipartTextMessage( + phoneNumber, + null, + parts, + null, + deliveryIntents + ) + } else { + smsManager.sendTextMessage( + phoneNumber, + null, + message, + null, + deliveryPendingIntent + ) + } + + Log.d(TAG, "SMS sent successfully: $messageId") + + } catch (e: Exception) { + Log.e(TAG, "Failed to send SMS: $messageId", e) + reportDeliveryFailure(messageId, e.message ?: "Unknown error") + } +} +``` + +## Sending Delivery Reports + +After SMS is delivered (or fails), report back to server: + +### Delivery Report Format + +```kotlin +fun sendDeliveryReport( + messageId: String, + status: String, // "delivered", "failed", "sent" + errorMessage: String? = null +) { + val data = mutableMapOf( + "action" to "delivery_report", + "message_id" to messageId, + "status" to status + ) + + if (errorMessage != null) { + data["error_message"] = errorMessage + } + + sendChannelMessage("delivery_report", data) + Log.d(TAG, "Delivery report sent: $messageId -> $status") +} +``` + +### Broadcast Receiver for Delivery Status + +```kotlin +class SmsDeliveryReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val messageId = intent.getStringExtra("message_id") ?: return + val phoneNumber = intent.getStringExtra("phone_number") ?: return + + when (resultCode) { + Activity.RESULT_OK -> { + Log.d(TAG, "SMS delivered: $messageId") + webSocketClient.sendDeliveryReport( + messageId = messageId, + status = "delivered" + ) + } + SmsManager.RESULT_ERROR_GENERIC_FAILURE -> { + Log.e(TAG, "SMS failed (generic): $messageId") + webSocketClient.sendDeliveryReport( + messageId = messageId, + status = "failed", + errorMessage = "Generic failure" + ) + } + SmsManager.RESULT_ERROR_NO_SERVICE -> { + Log.e(TAG, "SMS failed (no service): $messageId") + webSocketClient.sendDeliveryReport( + messageId = messageId, + status = "failed", + errorMessage = "No service" + ) + } + SmsManager.RESULT_ERROR_RADIO_OFF -> { + Log.e(TAG, "SMS failed (radio off): $messageId") + webSocketClient.sendDeliveryReport( + messageId = messageId, + status = "failed", + errorMessage = "Radio off" + ) + } + SmsManager.RESULT_ERROR_NULL_PDU -> { + Log.e(TAG, "SMS failed (null PDU): $messageId") + webSocketClient.sendDeliveryReport( + messageId = messageId, + status = "failed", + errorMessage = "Null PDU" + ) + } + } + } +} +``` + +### Register Receiver in Manifest + +```xml + + + + + +``` + +## Reporting Received SMS + +When Android receives an SMS, forward it to the server: + +### Received SMS Format + +```kotlin +fun reportReceivedSms( + phoneNumber: String, + messageBody: String, + receivedAt: Long = System.currentTimeMillis() +) { + val data = mapOf( + "action" to "received_sms", + "phone_number" to phoneNumber, + "message_body" to messageBody, + "received_at" to receivedAt + ) + + sendChannelMessage("received_sms", data) + Log.d(TAG, "Received SMS reported: from $phoneNumber") +} +``` + +### SMS Receiver Implementation + +```kotlin +class SmsReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Telephony.Sms.Intents.SMS_RECEIVED_ACTION) { + val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent) + + for (message in messages) { + val sender = message.originatingAddress ?: continue + val body = message.messageBody ?: continue + val timestamp = message.timestampMillis + + Log.d(TAG, "SMS received from $sender") + + // Report to server via WebSocket + webSocketClient.reportReceivedSms( + phoneNumber = sender, + messageBody = body, + receivedAt = timestamp + ) + } + } + } +} +``` + +## Reconnection Strategy + +Implement automatic reconnection with exponential backoff: + +```kotlin +class ReconnectionManager( + private val webSocketClient: GatewayWebSocketClient +) { + private val handler = Handler(Looper.getMainLooper()) + private var reconnectAttempts = 0 + private val maxReconnectAttempts = 10 + private val baseDelay = 1000L // 1 second + + fun scheduleReconnect() { + if (reconnectAttempts >= maxReconnectAttempts) { + Log.e(TAG, "Max reconnection attempts reached") + return + } + + val delay = calculateBackoffDelay(reconnectAttempts) + reconnectAttempts++ + + Log.d(TAG, "Scheduling reconnection attempt $reconnectAttempts in ${delay}ms") + + handler.postDelayed({ + Log.d(TAG, "Attempting reconnection...") + webSocketClient.connect() + }, delay) + } + + fun reset() { + reconnectAttempts = 0 + handler.removeCallbacksAndMessages(null) + } + + private fun calculateBackoffDelay(attempt: Int): Long { + // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, 60s, 60s... + val exponentialDelay = baseDelay * (1 shl attempt) + return minOf(exponentialDelay, 60_000L) // Cap at 60 seconds + } +} +``` + +### Integrate with WebSocket Client + +```kotlin +class GatewayWebSocketClient( + private val baseUrl: String, + private val apiKey: String, + private val listener: WebSocketEventListener +) { + private val reconnectionManager = ReconnectionManager(this) + + // ... existing code ... + + override fun onOpen(webSocket: WebSocket, response: Response) { + Log.d(TAG, "WebSocket connected") + reconnectionManager.reset() // Reset reconnection attempts + listener.onConnected() + subscribe() + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + Log.e(TAG, "WebSocket error", t) + listener.onError(t) + reconnectionManager.scheduleReconnect() // Automatically reconnect + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + Log.d(TAG, "WebSocket closed: $code $reason") + listener.onDisconnected() + + // Only reconnect if not intentionally closed + if (code != 1000) { + reconnectionManager.scheduleReconnect() + } + } +} +``` + +## Complete Android Service + +Combine everything into a foreground service: + +```kotlin +class GatewayService : Service() { + private lateinit var webSocketClient: GatewayWebSocketClient + private lateinit var heartbeatManager: HeartbeatManager + private val notificationId = 1001 + private val channelId = "gateway_service" + + override fun onCreate() { + super.onCreate() + + // Load configuration + val prefs = getSharedPreferences("gateway_config", MODE_PRIVATE) + val apiKey = prefs.getString("api_key", null) ?: return + val baseUrl = prefs.getString("base_url", null) ?: return + + // Initialize WebSocket client + webSocketClient = GatewayWebSocketClient( + baseUrl = baseUrl, + apiKey = apiKey, + listener = object : WebSocketEventListener { + override fun onConnected() { + Log.d(TAG, "Connected to server") + updateNotification("Connected") + } + + override fun onDisconnected() { + Log.d(TAG, "Disconnected from server") + updateNotification("Disconnected") + } + + override fun onSubscribed() { + Log.d(TAG, "Subscribed to GatewayChannel") + heartbeatManager.start() + } + + override fun onError(error: Throwable) { + Log.e(TAG, "WebSocket error", error) + updateNotification("Error: ${error.message}") + } + } + ) + + // Initialize heartbeat manager + heartbeatManager = HeartbeatManager(webSocketClient) + + // Create notification channel + createNotificationChannel() + + // Start as foreground service + startForeground(notificationId, createNotification("Starting...")) + + // Connect to server + webSocketClient.connect() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + return START_STICKY // Restart if killed + } + + override fun onDestroy() { + super.onDestroy() + heartbeatManager.stop() + webSocketClient.disconnect() + } + + override fun onBind(intent: Intent?): IBinder? = null + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + channelId, + "Gateway Service", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "SMS Gateway background service" + } + + val notificationManager = getSystemService(NotificationManager::class.java) + notificationManager.createNotificationChannel(channel) + } + } + + private fun createNotification(status: String): Notification { + return NotificationCompat.Builder(this, channelId) + .setContentTitle("SMS Gateway Active") + .setContentText(status) + .setSmallIcon(R.drawable.ic_notification) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setOngoing(true) + .build() + } + + private fun updateNotification(status: String) { + val notificationManager = getSystemService(NotificationManager::class.java) + notificationManager.notify(notificationId, createNotification(status)) + } + + companion object { + private const val TAG = "GatewayService" + } +} +``` + +### Start Service from Activity + +```kotlin +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Start gateway service + val serviceIntent = Intent(this, GatewayService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(serviceIntent) + } else { + startService(serviceIntent) + } + } +} +``` + +### Service in Manifest + +```xml + +``` + +## Error Handling + +### Connection Errors + +```kotlin +interface WebSocketEventListener { + fun onConnected() + fun onDisconnected() + fun onSubscribed() + fun onError(error: Throwable) + fun onClosing(code: Int, reason: String) +} +``` + +### Common Errors + +| Error | Cause | Solution | +|-------|-------|----------| +| **401 Unauthorized** | Invalid API key | Verify API key is correct and starts with `gw_live_` | +| **403 Forbidden** | Origin not allowed | Check Action Cable allowed origins in server config | +| **Connection timeout** | Server unreachable | Verify server URL and network connectivity | +| **Connection closed (1006)** | Abnormal closure | Check server logs, implement reconnection | +| **Subscription rejected** | Channel not found | Ensure using `GatewayChannel` identifier | + +## Testing WebSocket Connection + +### Test Tool: wscat + +Install wscat (Node.js tool): +```bash +npm install -g wscat +``` + +Connect to server: +```bash +wscat -c "ws://192.168.1.100:3000/cable?api_key=gw_live_your_key_here" +``` + +Subscribe to channel: +```json +{"command":"subscribe","identifier":"{\"channel\":\"GatewayChannel\"}"} +``` + +Send heartbeat: +```json +{"command":"message","identifier":"{\"channel\":\"GatewayChannel\"}","data":"{\"action\":\"heartbeat\",\"device_info\":{\"battery_level\":85}}"} +``` + +## Production Configuration + +### Use WSS (WebSocket Secure) + +Always use encrypted WebSocket in production: + +```kotlin +val baseUrl = "https://api.yourdomain.com" +val wsUrl = baseUrl.replace("https://", "wss://") +// Result: "wss://api.yourdomain.com" +``` + +### SSL Certificate Pinning + +For additional security, implement certificate pinning: + +```kotlin +val certificatePinner = CertificatePinner.Builder() + .add("api.yourdomain.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") + .build() + +val client = OkHttpClient.Builder() + .certificatePinner(certificatePinner) + .build() +``` + +### Server Configuration + +In production, configure Action Cable in `config/cable.yml`: + +```yaml +production: + adapter: redis + url: <%= ENV.fetch("REDIS_URL") %> + channel_prefix: my_smsa_pio_production +``` + +And in `config/environments/production.rb`: + +```ruby +config.action_cable.url = "wss://api.yourdomain.com/cable" +config.action_cable.allowed_request_origins = [ + "https://yourdomain.com", + /https:\/\/.*\.yourdomain\.com/ +] +``` + +## Message Flow Summary + +### Outbound SMS Flow + +1. **Server → Android**: Server sends `send_sms` action via WebSocket +2. **Android**: Receives message, sends SMS via SmsManager +3. **Android → Server**: Reports delivery status via `delivery_report` action + +### Inbound SMS Flow + +1. **Android**: Receives SMS via BroadcastReceiver +2. **Android → Server**: Reports received SMS via `received_sms` action +3. **Server**: Processes and triggers webhooks if configured + +### Heartbeat Flow + +1. **Android → Server**: Sends `heartbeat` action every 60 seconds +2. **Server**: Updates `last_heartbeat_at` timestamp +3. **Server → Android**: Sends `heartbeat_ack` confirmation + +## Debugging Tips + +### Enable Logging + +```kotlin +val loggingInterceptor = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY +} + +val client = OkHttpClient.Builder() + .addInterceptor(loggingInterceptor) + .build() +``` + +### Monitor Messages + +```kotlin +override fun onMessage(webSocket: WebSocket, text: String) { + Log.d(TAG, "← Received: $text") + handleMessage(text) +} + +private fun sendChannelMessage(action: String, data: Map) { + val message = buildMessage(action, data) + Log.d(TAG, "→ Sending: ${message.toString()}") + webSocket?.send(message.toString()) +} +``` + +### Check Server Logs + +On the server, monitor Action Cable logs: + +```bash +# Development +tail -f log/development.log | grep "GatewayChannel" + +# Production (via Kamal) +bin/kamal logs | grep "GatewayChannel" +``` + +## Performance Considerations + +### Message Buffering + +If sending multiple messages rapidly, consider buffering: + +```kotlin +class MessageBuffer( + private val webSocketClient: GatewayWebSocketClient +) { + private val buffer = mutableListOf>>() + private val handler = Handler(Looper.getMainLooper()) + private val flushInterval = 1000L // 1 second + + fun enqueue(action: String, data: Map) { + buffer.add(action to data) + scheduleFlush() + } + + private fun scheduleFlush() { + handler.removeCallbacks(flushRunnable) + handler.postDelayed(flushRunnable, flushInterval) + } + + private val flushRunnable = Runnable { + if (buffer.isNotEmpty()) { + buffer.forEach { (action, data) -> + webSocketClient.sendMessage(action, data) + } + buffer.clear() + } + } +} +``` + +### Connection Pooling + +OkHttp handles connection pooling automatically, but you can configure it: + +```kotlin +val client = OkHttpClient.Builder() + .connectionPool(ConnectionPool(5, 5, TimeUnit.MINUTES)) + .build() +``` + +## Summary + +✅ **WebSocket Connection**: Connect to `/cable` with API key query parameter +✅ **Channel Subscription**: Subscribe to `GatewayChannel` after connection +✅ **Send SMS**: Receive `send_sms` actions from server +✅ **Delivery Reports**: Report SMS delivery status back to server +✅ **Receive SMS**: Forward received SMS to server via `received_sms` action +✅ **Heartbeat**: Send heartbeat every 60 seconds to maintain online status +✅ **Reconnection**: Implement exponential backoff for automatic reconnection +✅ **Foreground Service**: Run as foreground service for reliability +✅ **Error Handling**: Handle all connection and message errors gracefully +✅ **Production Ready**: Use WSS, certificate pinning, and proper logging + +**Complete Android integration with MySMSAPio WebSocket server!** 🚀 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5b12e11 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,430 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +MySMSAPio is an SMS Gateway Backend API - a Rails 8.0 REST API and WebSocket server for managing SMS messaging through Android gateway devices. It provides programmatic SMS capabilities with OTP management, webhooks, and real-time WebSocket communication. + +The project includes a web-based admin interface for managing API keys, monitoring SMS logs, and viewing gateway status. See [ADMIN_INTERFACE.md](ADMIN_INTERFACE.md) for detailed documentation. + +## Tech Stack + +- **Framework**: Rails 8.0.3 (API-only mode) +- **Ruby**: 3.4.7 +- **Database**: PostgreSQL 14+ +- **Cache/Queue**: Redis 7+ +- **Background Jobs**: Sidekiq 7 with sidekiq-cron +- **WebSocket**: Action Cable (Redis adapter) +- **Authentication**: API Keys with SHA256 hashing +- **Server**: Puma with Thruster for production +- **Deployment**: Kamal (Docker-based deployment) +- **Code Quality**: RuboCop with rails-omakase configuration, Brakeman for security + +## SMS Gateway Dependencies + +- **redis**: WebSocket, caching, background jobs +- **sidekiq**: Background job processing +- **sidekiq-cron**: Scheduled jobs (health checks, cleanup) +- **jwt**: API authentication tokens +- **rack-cors**: Cross-origin resource sharing +- **phonelib**: Phone number validation +- **rotp**: OTP generation +- **httparty**: Webhook HTTP requests +- **pagy**: Pagination + +## Development Commands + +### Initial Setup +```bash +bin/setup +``` +This will: +- Install dependencies +- Prepare the database +- Clear logs and temp files +- Start the development server + +To skip auto-starting the server: +```bash +bin/setup --skip-server +``` + +### Running the Application +```bash +bin/dev +# or +bin/rails server +``` + +### Database + +**Create and migrate databases:** +```bash +bin/rails db:create +bin/rails db:migrate +``` + +**Prepare database (create + migrate + seed):** +```bash +bin/rails db:prepare +``` + +**Reset database:** +```bash +bin/rails db:reset +``` + +**Database console:** +```bash +bin/rails dbconsole +# or via Kamal: +bin/kamal dbc +``` + +### Testing + +**Run all tests:** +```bash +bin/rails test +``` + +**Run specific test file:** +```bash +bin/rails test test/models/your_model_test.rb +``` + +**Run specific test by line number:** +```bash +bin/rails test test/models/your_model_test.rb:42 +``` + +**System tests:** +```bash +bin/rails test:system +``` + +Tests run in parallel using all processor cores by default. + +### Code Quality + +**Run RuboCop:** +```bash +bin/rubocop +``` + +**Auto-correct RuboCop violations:** +```bash +bin/rubocop -a +``` + +**Run Brakeman security scanner:** +```bash +bin/brakeman +``` + +### Rails Console +```bash +bin/rails console +# or via Kamal: +bin/kamal console +``` + +### Background Jobs (Sidekiq) + +**Start Sidekiq:** +```bash +bundle exec sidekiq +``` + +**View scheduled jobs:** +```bash +bundle exec sidekiq-cron +``` + +**Scheduled jobs run automatically**: +- `CheckGatewayHealthJob`: Every minute +- `CleanupExpiredOtpsJob`: Every 15 minutes +- `ResetDailyCountersJob`: Daily at midnight + +### Asset Management + +**Precompile assets:** +```bash +bin/rails assets:precompile +``` + +**Clear compiled assets:** +```bash +bin/rails assets:clobber +``` + +### Deployment (Kamal) + +**Deploy application:** +```bash +bin/kamal deploy +``` + +**View logs:** +```bash +bin/kamal logs +``` + +**Access remote shell:** +```bash +bin/kamal shell +``` + +**View configuration:** +```bash +bin/kamal config +``` + +The deployment configuration is in `config/deploy.yml`. Production uses Docker containers with SSL enabled via Let's Encrypt. + +## Database Architecture + +### Development/Test +Single PostgreSQL database per environment: +- Development: `my_smsa_pio_development` +- Test: `my_smsa_pio_test` + +### Production +Multi-database setup for performance isolation: +- **Primary**: Main application data +- **Cache**: Database-backed cache via Solid Cache (migrations in `db/cache_migrate/`) +- **Queue**: Job queue via Solid Queue (migrations in `db/queue_migrate/`) +- **Cable**: WebSocket connections via Solid Cable (migrations in `db/cable_migrate/`) + +Each database shares connection pooling configuration but maintains separate migration paths. + +## Application Structure + +The application follows standard Rails 8 conventions with the modern "Omakase" stack: + +- **app/**: Standard Rails application code (models, controllers, views, jobs, mailers, helpers) +- **config/**: Configuration files including multi-database setup +- **db/**: Database schemas with separate migration directories for each database role +- **lib/**: Custom library code (autoloaded via `config.autoload_lib`) +- **test/**: Minitest-based test suite with system tests using Capybara + Selenium + +## Key Configuration Details + +### Module Name +The Rails application module is `MySmsaPio` (defined in `config/application.rb`). + +### Environment Variables + +**Required**: +- `DATABASE_URL`: PostgreSQL connection string +- `REDIS_URL`: Redis connection string (used for Action Cable, Sidekiq, caching) +- `SECRET_KEY_BASE`: Rails secret key +- `RAILS_ENV`: Environment (development, test, production) + +**Optional**: +- `ALLOWED_ORIGINS`: CORS allowed origins (default: `*`) +- `DEFAULT_COUNTRY_CODE`: Default country for phone validation (default: `US`) +- `RAILS_LOG_LEVEL`: Logging level + +### Docker & Production + +The application is containerized using a multi-stage Dockerfile optimized for production: +- Base Ruby 3.4.7 slim image +- Uses Thruster as the web server (listens on port 80) +- Non-root user (rails:rails, uid/gid 1000) +- Entrypoint handles database preparation via `bin/docker-entrypoint` +- Assets precompiled during build + +### Code Style + +Follow RuboCop Rails Omakase conventions. Configuration is minimal, inheriting from `rubocop-rails-omakase` gem. + +## Health Checks + +The application includes a health check endpoint: +``` +GET /up +``` +Returns 200 if app boots successfully, 500 otherwise. Used by load balancers and monitoring. + +--- + +## SMS Gateway Specific Information + +### Database Models + +**Gateway** (`app/models/gateway.rb`): +- Represents Android SMS gateway devices +- Tracks connection status, heartbeat, message counts +- Generates and stores API keys (hashed with SHA256) + +**SmsMessage** (`app/models/sms_message.rb`): +- Stores all SMS messages (inbound and outbound) +- Auto-generates unique message IDs +- Validates phone numbers using Phonelib +- Triggers SendSmsJob on creation for outbound messages + +**OtpCode** (`app/models/otp_code.rb`): +- Generates 6-digit OTP codes +- Enforces rate limiting (3 per phone per hour) +- Auto-expires after 5 minutes +- Tracks verification attempts (max 3) + +**WebhookConfig** (`app/models/webhook_config.rb`): +- Configures webhooks for SMS events +- Signs payloads with HMAC-SHA256 +- Supports retry logic + +**ApiKey** (`app/models/api_key.rb`): +- Client API keys for application access +- Permissions-based access control +- Tracks usage and expiration + +### API Controllers + +**Gateway APIs** (`app/controllers/api/v1/gateway/`): +- `RegistrationsController`: Register new gateway devices +- `HeartbeatsController`: Keep-alive from gateways +- `SmsController`: Report received SMS and delivery status + +**Client APIs** (`app/controllers/api/v1/`): +- `SmsController`: Send/receive SMS, check status +- `OtpController`: Generate and verify OTP codes +- `Admin::GatewaysController`: Manage gateway devices +- `Admin::StatsController`: System statistics + +### WebSocket Communication + +**GatewayChannel** (`app/channels/gateway_channel.rb`): +- Real-time bidirectional communication with gateway devices +- Authenticated via API key digest +- Handles: + - Gateway connection/disconnection + - Heartbeat messages + - Delivery reports + - Inbound SMS notifications + - Outbound SMS commands + +**Connection** (`app/channels/application_cable/connection.rb`): +- Authenticates WebSocket connections +- Verifies gateway API keys + +### Background Jobs + +**Processing Jobs**: +- `SendSmsJob`: Routes outbound SMS to available gateways via WebSocket +- `ProcessInboundSmsJob`: Triggers webhooks for received SMS +- `RetryFailedSmsJob`: Retries failed messages with exponential backoff +- `TriggerWebhookJob`: Executes webhook HTTP requests + +**Scheduled Jobs** (config/sidekiq_cron.yml): +- `CheckGatewayHealthJob`: Marks offline gateways (every minute) +- `CleanupExpiredOtpsJob`: Deletes expired OTP codes (every 15 minutes) +- `ResetDailyCountersJob`: Resets daily message counters (daily at midnight) + +### Admin Web Interface + +**Admin Authentication** (`app/models/admin.rb`, `app/controllers/admin/`): +- Session-based authentication with bcrypt password hashing +- Access URL: `/admin/login` +- Default credentials (development): admin@example.com / password123 + +**Admin Features**: +1. **Dashboard** (`/admin/dashboard`): Real-time statistics and recent activity +2. **API Keys Management** (`/admin/api_keys`): Create, view, and revoke API keys +3. **SMS Logs** (`/admin/logs`): Monitor messages with advanced filtering +4. **Gateway Management** (`/admin/gateways`): View and manage gateway devices + +**Admin Controllers**: +- `Admin::BaseController`: Base controller with authentication +- `Admin::SessionsController`: Login/logout +- `Admin::DashboardController`: Dashboard with stats +- `Admin::ApiKeysController`: API key CRUD operations +- `Admin::LogsController`: SMS logs with filtering +- `Admin::GatewaysController`: Gateway management + +See [ADMIN_INTERFACE.md](ADMIN_INTERFACE.md) for complete documentation. + +### Authentication & Security + +**API Key Types**: +1. **Gateway Keys** (`gw_live_...`): For Android gateway devices +2. **Client Keys** (`api_live_...`): For application APIs +3. **Admin Access**: Session-based web authentication + +**Authentication Flow**: +- API keys passed in `Authorization: Bearer ` header +- Keys are hashed with SHA256 before storage +- Admin passwords hashed with bcrypt +- Concerns: `ApiAuthenticatable`, `RateLimitable` + +**Rate Limiting**: +- Implemented via Redis caching +- OTP: Max 3 per phone per hour +- SMS Send: 100 per minute per API key +- Customizable per endpoint + +### Key Files + +**Models**: `app/models/{gateway,sms_message,otp_code,webhook_config,api_key}.rb` + +**Controllers**: `app/controllers/api/v1/**/*_controller.rb` + +**Jobs**: `app/jobs/{send_sms_job,process_inbound_sms_job,retry_failed_sms_job,trigger_webhook_job,check_gateway_health_job,cleanup_expired_otps_job,reset_daily_counters_job}.rb` + +**Channels**: `app/channels/{gateway_channel,application_cable/connection}.rb` + +**Concerns**: `app/controllers/concerns/{api_authenticatable,rate_limitable}.rb`, `app/models/concerns/metrics.rb` + +**Config**: +- `config/routes.rb`: API routes +- `config/cable.yml`: Action Cable (Redis) +- `config/sidekiq_cron.yml`: Scheduled jobs +- `config/initializers/{cors,sidekiq,phonelib,pagy}.rb` + +### Common Development Tasks + +**Generate new API key**: +```ruby +# In Rails console +result = ApiKey.generate!(name: "My App", permissions: { send_sms: true, receive_sms: true }) +puts result[:raw_key] # Save this immediately! +``` + +**Register gateway manually**: +```ruby +# In Rails console +gateway = Gateway.new(device_id: "my-device-001", name: "My Gateway") +api_key = gateway.generate_api_key! +puts api_key # Save this immediately! +``` + +**Check gateway status**: +```ruby +# In Rails console +Gateway.online.each { |g| puts "#{g.name}: #{g.status}" } +``` + +**View pending messages**: +```ruby +# In Rails console +SmsMessage.pending.count +SmsMessage.failed.each { |msg| puts "#{msg.message_id}: #{msg.error_message}" } +``` + +**Test WebSocket connection**: +```bash +# Use wscat or similar WebSocket client +wscat -c "ws://localhost:3000/cable?api_key=gw_live_your_key_here" +``` + +### Important Notes + +- **Redis is required** for Action Cable, Sidekiq, and caching to work +- All phone numbers are validated and normalized using Phonelib +- Gateway devices must send heartbeats every 2 minutes or they'll be marked offline +- Outbound SMS messages are queued and sent asynchronously via Sidekiq +- Failed messages retry automatically up to 3 times with exponential backoff +- OTP codes expire after 5 minutes and allow max 3 verification attempts +- All API keys are SHA256 hashed - raw keys are only shown once during creation diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b258fd2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,72 @@ +# syntax=docker/dockerfile:1 +# check=error=true + +# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand: +# docker build -t my_smsa_pio . +# docker run -d -p 80:80 -e RAILS_MASTER_KEY= --name my_smsa_pio my_smsa_pio + +# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html + +# Make sure RUBY_VERSION matches the Ruby version in .ruby-version +ARG RUBY_VERSION=3.4.7 +FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base + +# Rails app lives here +WORKDIR /rails + +# Install base packages +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y curl libjemalloc2 libvips postgresql-client && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Set production environment +ENV RAILS_ENV="production" \ + BUNDLE_DEPLOYMENT="1" \ + BUNDLE_PATH="/usr/local/bundle" \ + BUNDLE_WITHOUT="development" + +# Throw-away build stage to reduce size of final image +FROM base AS build + +# Install packages needed to build gems +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential git libpq-dev libyaml-dev pkg-config && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Install application gems +COPY Gemfile Gemfile.lock ./ +RUN bundle install && \ + rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ + bundle exec bootsnap precompile --gemfile + +# Copy application code +COPY . . + +# Precompile bootsnap code for faster boot times +RUN bundle exec bootsnap precompile app/ lib/ + +# Precompiling assets for production without requiring secret RAILS_MASTER_KEY +RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile + + + + +# Final stage for app image +FROM base + +# Copy built artifacts: gems, application +COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}" +COPY --from=build /rails /rails + +# Run and own only the runtime files as a non-root user for security +RUN groupadd --system --gid 1000 rails && \ + useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \ + chown -R rails:rails db log storage tmp +USER 1000:1000 + +# Entrypoint prepares the database. +ENTRYPOINT ["/rails/bin/docker-entrypoint"] + +# Start server via Thruster by default, this can be overwritten at runtime +EXPOSE 80 +CMD ["./bin/thrust", "./bin/rails", "server"] diff --git a/FIXES_APPLIED.md b/FIXES_APPLIED.md new file mode 100644 index 0000000..498f50a --- /dev/null +++ b/FIXES_APPLIED.md @@ -0,0 +1,355 @@ +# All Fixes Applied - MySMSAPio Admin Interface + +## Issues Resolved ✅ + +### 1. ✅ Namespace Conflict: "Admin is not a module" +**Problem:** Model class `Admin` conflicted with `Admin` module namespace + +**Solution Applied:** +- Renamed model: `Admin` → `AdminUser` +- Updated table: `admins` → `admin_users` +- Updated all controllers and seeds +- Migration: `20251020031401_rename_admins_to_admin_users.rb` + +**Files Changed:** +- `app/models/admin_user.rb` (renamed from admin.rb) +- `app/controllers/admin/base_controller.rb` +- `app/controllers/admin/sessions_controller.rb` +- `db/seeds.rb` +- `test/models/admin_user_test.rb` +- `test/fixtures/admin_users.yml` + +--- + +### 2. ✅ Session & Flash Error: "undefined method 'flash'" +**Problem:** Application in API-only mode disabled sessions and flash + +**Solution Applied:** +- Disabled `config.api_only` mode in `config/application.rb` +- Added `config/initializers/session_store.rb` +- API controllers still use `ActionController::API` (fast) +- Admin controllers use `ActionController::Base` (full features) + +**Files Changed:** +- `config/application.rb` - Commented out `api_only = true` +- `config/initializers/session_store.rb` - New file +- `app/controllers/admin/base_controller.rb` - Added CSRF protection +- `app/controllers/admin/sessions_controller.rb` - Added CSRF protection + +**Middleware Added:** +``` +use ActionDispatch::Cookies +use ActionDispatch::Session::CookieStore +use ActionDispatch::Flash +``` + +--- + +### 3. ✅ Helper Method Error: "undefined method 'logged_in?'" +**Problem:** Helper methods not accessible in layout before controller runs + +**Solution Applied:** +- Added helper methods to `ApplicationHelper` +- Methods: `current_admin`, `logged_in?` +- Also created `AdminHelper` for admin-specific helpers + +**Files Changed:** +- `app/helpers/application_helper.rb` - Added auth helper methods +- `app/helpers/admin_helper.rb` - New file + +**Helper Methods Added:** +```ruby +def current_admin + @current_admin ||= AdminUser.find_by(id: session[:admin_id]) if session[:admin_id] +end + +def logged_in? + current_admin.present? +end +``` + +--- + +## Current Application State + +### Architecture + +``` +MySMSAPio (Hybrid Rails App) +│ +├── API Endpoints (ActionController::API) +│ ├── Fast, stateless, token-based auth +│ ├── /api/v1/sms/* +│ ├── /api/v1/otp/* +│ └── /api/v1/gateway/* +│ +└── Admin Interface (ActionController::Base) + ├── Full Rails features, session-based auth + ├── /admin/login + ├── /admin/dashboard + ├── /admin/api_keys + ├── /admin/logs + └── /admin/gateways +``` + +### Database Schema + +```ruby +create_table "admin_users" do |t| + t.string :email, null: false, index: {unique: true} + t.string :password_digest, null: false + t.string :name, null: false + t.datetime :last_login_at + t.timestamps +end +``` + +### Authentication Flow + +**Admin Interface:** +1. User visits `/admin/login` +2. Enters email/password +3. `AdminUser.authenticate` verifies credentials +4. Session stored with `session[:admin_id]` +5. Flash messages show success/error +6. CSRF token validates all forms + +**API Endpoints:** +1. Client sends request with `Authorization: Bearer api_key` +2. `ApiAuthenticatable` concern validates token +3. No session created +4. Fast, stateless response + +### Configuration Files + +**Key Configuration:** +- `config/application.rb` - API-only mode disabled +- `config/initializers/session_store.rb` - Session configuration +- `config/routes.rb` - Admin routes under `/admin` namespace + +**Controllers:** +- `app/controllers/application_controller.rb` - Base for API (ActionController::API) +- `app/controllers/admin/base_controller.rb` - Base for Admin (ActionController::Base) +- All admin controllers inherit from `Admin::BaseController` + +**Helpers:** +- `app/helpers/application_helper.rb` - Global helpers including auth +- `app/helpers/admin_helper.rb` - Admin-specific helpers + +--- + +## How to Start + +### 1. Ensure Database is Migrated +```bash +bin/rails db:migrate +bin/rails db:seed +``` + +### 2. Start the Server +```bash +# Option A: With Tailwind CSS watch (Recommended) +bin/dev + +# Option B: Rails server only +bin/rails server +``` + +### 3. Access Admin Interface +``` +URL: http://localhost:3000/admin/login +Email: admin@example.com +Password: password123 +``` + +--- + +## Verification Steps + +### Check Database +```bash +bin/rails runner "puts 'AdminUsers: ' + AdminUser.count.to_s" +# Should output: AdminUsers: 1 +``` + +### Check Routes +```bash +bin/rails routes | grep admin | head -5 +# Should show admin routes +``` + +### Check Middleware +```bash +bin/rails middleware | grep -E "Session|Flash|Cookies" +# Should show: +# use ActionDispatch::Cookies +# use ActionDispatch::Session::CookieStore +# use ActionDispatch::Flash +``` + +### Check Models +```bash +bin/rails runner "puts AdminUser.first.email" +# Should output: admin@example.com +``` + +--- + +## Features Working + +### ✅ Admin Dashboard +- Real-time statistics (gateways, API keys, messages) +- Recent messages table with status badges +- Gateway status with pulse animations +- Responsive grid layout + +### ✅ API Keys Management +- List all API keys with permissions +- Create new keys with checkboxes +- One-time key display with copy button +- Revoke keys with confirmation +- Status indicators (active/revoked/expired) + +### ✅ SMS Logs +- Paginated message list (50 per page) +- Advanced filters (direction, status, phone, gateway, dates) +- Click to expand error messages +- Color-coded status badges +- Retry count indicators + +### ✅ Gateway Management +- List all gateway devices +- Animated online/offline indicators +- Message statistics (today and total) +- Activate/deactivate controls +- Detailed gateway view with stats cards + +### ✅ Authentication & Security +- Session-based login +- Bcrypt password hashing +- CSRF protection on all forms +- Flash messages for user feedback +- Automatic session expiration +- "Remember me" capability + +### ✅ Professional UI +- Tailwind CSS v4 +- Dark sidebar with gradient +- Responsive design (mobile/tablet/desktop) +- Font Awesome icons +- Smooth transitions +- Hover effects +- Status pulse animations + +--- + +## API Endpoints (Unaffected) + +All API endpoints work exactly as before: + +```bash +# Send SMS +POST /api/v1/sms/send +Authorization: Bearer api_live_xxx + +# Get SMS status +GET /api/v1/sms/status/:message_id +Authorization: Bearer api_live_xxx + +# Gateway registration +POST /api/v1/gateway/register + +# And more... +``` + +--- + +## Security Considerations + +### Production Checklist +- [ ] Change default admin password +- [ ] Enable HTTPS (`config.force_ssl = true`) +- [ ] Set secure session cookies +- [ ] Configure CORS properly +- [ ] Set strong SECRET_KEY_BASE +- [ ] Enable rate limiting +- [ ] Monitor admin access logs +- [ ] Regular security audits + +### Current Security Features +✅ Bcrypt password hashing (cost: 12) +✅ CSRF protection enabled +✅ SQL injection protection (ActiveRecord) +✅ XSS protection (ERB escaping) +✅ Session hijacking protection (encrypted cookies) +✅ Mass assignment protection (strong parameters) + +--- + +## Documentation + +- 📖 `README.md` - Project overview +- 📖 `CLAUDE.md` - Development guidelines +- 📖 `ADMIN_INTERFACE.md` - Complete admin documentation +- 📖 `ADMIN_QUICKSTART.md` - Quick reference +- 📖 `STARTUP_GUIDE.md` - Detailed startup instructions +- 📖 `NAMESPACE_FIX.md` - Namespace conflict explanation +- 📖 `SESSION_MIDDLEWARE_FIX.md` - Middleware configuration +- 📖 `FIXES_APPLIED.md` - This file + +--- + +## Troubleshooting + +### Server Won't Start +```bash +# Check for syntax errors +bin/rails runner "puts 'OK'" + +# Check logs +tail -f log/development.log +``` + +### Login Not Working +```bash +# Verify admin exists +bin/rails runner "puts AdminUser.first.inspect" + +# Check session middleware +bin/rails middleware | grep Session +``` + +### Layout Not Loading +```bash +# Rebuild assets +bin/rails assets:precompile +bin/rails tailwindcss:build +``` + +### API Endpoints Broken +**They shouldn't be!** API endpoints use different controllers. If you see issues: +```bash +# Check API routes +bin/rails routes | grep api/v1 + +# Test API endpoint +curl -v http://localhost:3000/api/v1/admin/gateways \ + -H "Authorization: Bearer api_live_xxx" +``` + +--- + +## Summary + +🎉 **All issues resolved!** + +The MySMSAPio application now has a fully functional admin interface with: +- ✅ Professional Tailwind CSS design +- ✅ Session-based authentication +- ✅ Flash message support +- ✅ No namespace conflicts +- ✅ Proper helper method availability +- ✅ API endpoints unaffected and working +- ✅ Production-ready security features + +**Ready to use! Start the server with `bin/dev` and visit http://localhost:3000/admin/login** diff --git a/GATEWAY_MANAGEMENT.md b/GATEWAY_MANAGEMENT.md new file mode 100644 index 0000000..c57abb8 --- /dev/null +++ b/GATEWAY_MANAGEMENT.md @@ -0,0 +1,378 @@ +# Gateway Management - Admin Interface + +## Overview + +The admin interface now includes complete gateway management functionality, allowing administrators to register new Android SMS gateway devices and manage existing ones. + +## Features Added + +### 1. Register New Gateway + +**URL**: `/admin/gateways/new` + +**Form Fields**: +- **Device ID** (required): Unique identifier for the gateway device +- **Gateway Name** (required): Friendly name for identification +- **Priority** (1-10): Priority level for message routing (higher priority = used first) + +**Process**: +1. Admin fills out the registration form +2. System creates gateway record with status "offline" +3. System generates unique API key (format: `gw_live_...`) +4. Redirects to gateway details page showing the API key + +### 2. Gateway Creation Success Page + +After creating a gateway, the admin sees: + +**Key Information Displayed**: +- ✅ Warning banner: "Save this API key now - you won't see it again!" +- ✅ Gateway API key in copy-to-clipboard format +- ✅ Gateway details (Device ID, Name, Priority, Status) +- ✅ Next steps instructions for configuring the Android app + +**Copy to Clipboard**: +- Click button to copy API key +- Visual feedback (button turns green, shows "Copied!") +- API key displayed in terminal-style format (dark background, green text) + +### 3. Gateway List Page + +**URL**: `/admin/gateways` + +**Features**: +- "Register New Gateway" button (top right) +- Table showing all gateways with: + - Name (clickable link to details) + - Device ID + - Status (Online/Offline with animated pulse for online) + - Active/Inactive status + - Priority level + - Today's message counts + - Total message counts + - Last heartbeat timestamp + - Created date + - Actions (View Details, Toggle Active) + +### 4. Gateway Details Page (Existing Gateway) + +**URL**: `/admin/gateways/:id` + +**For existing gateways** (not newly created): +- Complete statistics dashboard +- Connection status indicators +- Message statistics (sent/received today and total) +- Recent messages table +- Device metadata (if available) +- Activate/Deactivate button + +--- + +## API Key Security + +### Key Generation + +```ruby +# Format: gw_live_ + 64 character hex string +raw_key = "gw_live_#{SecureRandom.hex(32)}" +api_key_digest = Digest::SHA256.hexdigest(raw_key) +``` + +**Security Features**: +- Raw key shown only once during creation +- Stored as SHA256 hash in database +- Keys cannot be retrieved after creation +- Unique per gateway + +### Session-Based Key Display + +Similar to API key creation, gateway keys use session storage: + +```ruby +# On create: +session[:new_gateway_id] = gateway.id +session[:new_gateway_raw_key] = raw_key + +# On show: +if session[:new_gateway_id] == @gateway.id && session[:new_gateway_raw_key].present? + @raw_key = session[:new_gateway_raw_key] + @is_new = true + # Clear session immediately + session.delete(:new_gateway_id) + session.delete(:new_gateway_raw_key) +end +``` + +**Benefits**: +- Prevents accidental key exposure via URL +- One-time display only +- Secure redirect pattern +- No key in browser history + +--- + +## Routes Added + +```ruby +resources :gateways, only: [:index, :new, :create, :show] do + member do + post :toggle + end +end +``` + +**Available Routes**: +- `GET /admin/gateways` - List all gateways +- `GET /admin/gateways/new` - New gateway form +- `POST /admin/gateways` - Create gateway +- `GET /admin/gateways/:id` - View gateway details (or show new key) +- `POST /admin/gateways/:id/toggle` - Activate/deactivate gateway + +--- + +## Controller Actions + +### `new` +Renders the registration form with empty gateway object. + +### `create` +```ruby +def create + @gateway = Gateway.new( + device_id: params[:gateway][:device_id], + name: params[:gateway][:name], + priority: params[:gateway][:priority] || 1, + status: "offline" + ) + + raw_key = @gateway.generate_api_key! + + # Store in session + session[:new_gateway_id] = @gateway.id + session[:new_gateway_raw_key] = raw_key + + redirect_to admin_gateway_path(@gateway) +end +``` + +**Error Handling**: +- Validates device_id uniqueness +- Validates required fields +- Shows error messages on form +- Logs errors for debugging + +### `show` +Conditional rendering based on `@is_new`: + +**If newly created**: +- Shows API key with copy button +- Shows basic gateway info +- Shows next steps instructions +- No statistics (gateway not connected yet) + +**If existing gateway**: +- Shows full statistics dashboard +- Shows recent messages +- Shows device metadata +- Shows activate/deactivate controls + +--- + +## Gateway Model + +### Required Fields +```ruby +validates :device_id, presence: true, uniqueness: true +validates :api_key_digest, presence: true +validates :status, inclusion: { in: %w[online offline error] } +``` + +### Default Values +- `status`: "offline" +- `priority`: 1 +- `active`: true +- `metadata`: {} (empty JSONB hash) +- Message counters: 0 + +### Methods + +**`generate_api_key!`**: +```ruby +def generate_api_key! + raw_key = "gw_live_#{SecureRandom.hex(32)}" + self.api_key_digest = Digest::SHA256.hexdigest(raw_key) + save! + raw_key +end +``` + +**`online?`**: +Checks if gateway is truly online based on heartbeat: +```ruby +def online? + status == "online" && last_heartbeat_at.present? && last_heartbeat_at > 2.minutes.ago +end +``` + +--- + +## View Files Created/Modified + +### New Files +- `app/views/admin/gateways/new.html.erb` - Registration form + +### Modified Files +- `app/views/admin/gateways/index.html.erb` - Added "Register New Gateway" button +- `app/views/admin/gateways/show.html.erb` - Added conditional for new gateway display + +--- + +## Usage Flow + +### Registering a New Gateway + +1. **Navigate to Gateways** + - Click "Gateways" in sidebar + - See list of existing gateways (if any) + +2. **Start Registration** + - Click "Register New Gateway" button (top right) + - Form appears with 3 fields + +3. **Fill Form** + - Device ID: `phone-samsung-001` + - Gateway Name: `Office Android Phone` + - Priority: `5` (1-10 scale) + +4. **Submit** + - Click "Register Gateway" button + - System creates gateway and generates API key + +5. **Save API Key** + - Warning shown: "Save this key now!" + - API key displayed: `gw_live_a6e2b250dade...` + - Click "Copy to Clipboard" button + - Key copied to clipboard + +6. **Configure Android App** + - Follow "Next Steps" instructions + - Install Android SMS Gateway app + - Paste API key in app settings + - Start gateway service + +7. **Verify Connection** + - Gateway appears "Offline" initially + - Once Android app connects, status changes to "Online" + - View gateway details to see statistics + +--- + +## Next Steps Instructions (Shown on Success Page) + +1. Copy the API key above using the "Copy to Clipboard" button +2. Install the Android SMS Gateway app on your device +3. Open the app and navigate to Settings +4. Paste the API key in the app's configuration +5. Save the configuration and start the gateway service +6. The gateway will appear as "Online" once it connects successfully + +--- + +## Testing + +### Console Test +```bash +bin/rails console +``` + +```ruby +# Create gateway +gateway = Gateway.new( + device_id: "test-001", + name: "Test Gateway", + priority: 3, + status: "offline" +) + +# Generate API key +raw_key = gateway.generate_api_key! +puts "API Key: #{raw_key}" + +# Verify +gateway.reload +puts "Gateway ID: #{gateway.id}" +puts "Device ID: #{gateway.device_id}" +puts "Key Digest: #{gateway.api_key_digest}" +``` + +### Browser Test +1. Start server: `bin/dev` +2. Visit: `http://localhost:3000/admin/login` +3. Login with admin credentials +4. Click "Gateways" in sidebar +5. Click "Register New Gateway" +6. Fill form and submit +7. Verify API key is displayed +8. Verify copy to clipboard works +9. Click "Back to Gateways" +10. Verify new gateway appears in list + +--- + +## Integration with Android App + +The gateway API key is used by the Android app to: + +1. **Authenticate WebSocket Connection** + - Connect to: `ws://your-server.com/cable?api_key=gw_live_...` + - Connection class authenticates via `api_key_digest` + +2. **Send Heartbeats** + - POST `/api/v1/gateway/heartbeat` + - Header: `Authorization: Bearer gw_live_...` + - Keeps gateway status "online" + +3. **Report Received SMS** + - POST `/api/v1/gateway/sms/received` + - Header: `Authorization: Bearer gw_live_...` + - Sends inbound SMS to system + +4. **Report Delivery Status** + - POST `/api/v1/gateway/sms/status` + - Header: `Authorization: Bearer gw_live_...` + - Updates message delivery status + +--- + +## Security Considerations + +### Production Checklist +- ✅ API keys are SHA256 hashed before storage +- ✅ Raw keys shown only once during creation +- ✅ Keys transmitted via session (not URL) +- ✅ Device ID uniqueness enforced +- ✅ CSRF protection enabled on all forms +- ⚠️ Ensure HTTPS in production (`config.force_ssl = true`) +- ⚠️ Secure WebSocket connections (wss://) +- ⚠️ Implement rate limiting on registration endpoint + +### Recommended Practices +- Regularly audit gateway list +- Deactivate unused gateways +- Monitor heartbeat timestamps +- Alert on prolonged offline status +- Rotate keys if device is compromised +- Keep device firmware updated + +--- + +## Summary + +✅ **Complete Gateway Management**: Register, view, and manage SMS gateway devices +✅ **Secure API Key Generation**: One-time display with copy-to-clipboard +✅ **Professional UI**: Tailwind CSS styling with clear instructions +✅ **Session-Based Security**: Keys not exposed in URLs or browser history +✅ **Integration Ready**: Works with Android SMS Gateway app +✅ **Admin Controls**: Activate/deactivate, view statistics, monitor status + +The admin interface now provides everything needed to manage SMS gateway devices from registration to monitoring! diff --git a/GATEWAY_TESTING.md b/GATEWAY_TESTING.md new file mode 100644 index 0000000..56eeb31 --- /dev/null +++ b/GATEWAY_TESTING.md @@ -0,0 +1,614 @@ +# Gateway Testing via Admin Interface + +## Overview + +The admin interface now includes a comprehensive gateway testing module that allows you to: +- Check gateway connection status in real-time +- Send test SMS messages through specific gateways +- Verify gateway functionality without external tools +- Debug connection issues + +## Features + +### 1. Connection Status Check + +**Real-time Gateway Status**: +- ✅ Online/Offline detection +- ⏰ Last heartbeat timestamp +- 🕐 Time since last connection +- 🔄 One-click refresh + +**How It Works**: +- Checks if gateway sent heartbeat within last 2 minutes +- Displays exact last heartbeat time +- Shows human-readable "time ago" format +- Updates with AJAX (no page reload) + +### 2. Send Test SMS + +**Test Message Features**: +- 📱 Phone number validation +- ✉️ Custom message composition +- 📊 Character counter (160 char SMS limit) +- 📝 Multi-part SMS detection +- ✅ Success/failure feedback +- 🔍 Message ID tracking + +**Message Tracking**: +- Test messages marked with `metadata: { test: true }` +- Identifies sender as "admin_interface" +- Full message history in logs +- Same queue as regular messages + +## Access Points + +### From Gateway List + +**URL**: `/admin/gateways` + +Each gateway has a **Test** button in the Actions column: +- Blue button with flask icon +- Located next to Activate/Deactivate button +- Available for all gateways (online or offline) + +### From Gateway Details + +**URL**: `/admin/gateways/:id` + +**Test Gateway** button at the bottom: +- Blue button with flask icon +- Located above Activate/Deactivate button +- Opens dedicated testing page + +### Direct Testing Page + +**URL**: `/admin/gateways/:id/test` + +Full testing interface with: +- Connection status card +- Gateway information +- Test SMS form +- Real-time updates + +## Testing Interface + +### Page Layout + +``` +┌─────────────────────────────────────────┐ +│ Test Gateway: [Gateway Name] │ +│ [Back to Gateway Details] │ +├─────────────────────────────────────────┤ +│ Gateway Status │ +│ ┌───────────────────────┐ │ +│ │ ✅ Gateway is Online │ [Refresh] │ +│ │ Last heartbeat: 30s │ │ +│ └───────────────────────┘ │ +├─────────────────────────────────────────┤ +│ Connection Information │ +│ Device ID: android-001 │ +│ Name: Office Phone │ +│ Priority: 5 │ +│ Active: Yes │ +├─────────────────────────────────────────┤ +│ Send Test SMS │ +│ Phone Number: [+959123456789____] │ +│ Message: [This is a test___________] │ +│ [________________________] │ +│ 160 characters remaining │ +│ │ +│ [Send Test SMS] [Reset Form] │ +└─────────────────────────────────────────┘ +``` + +### Status Indicators + +**Online (Green)**: +``` +┌──────────────────────────────┐ +│ ✅ Gateway is Online │ +│ Last heartbeat: 1 minute ago │ +│ 2025-10-20 13:45:30 │ +└──────────────────────────────┘ +``` + +**Offline (Red)**: +``` +┌──────────────────────────────┐ +│ ❌ Gateway is Offline │ +│ Gateway is offline │ +│ Last seen: 5 hours ago │ +└──────────────────────────────┘ +``` + +**Never Connected (Red)**: +``` +┌──────────────────────────────┐ +│ ❌ Gateway is Offline │ +│ Gateway is offline │ +│ Never connected │ +└──────────────────────────────┘ +``` + +## Using the Test Feature + +### Step 1: Access Testing Page + +**Option A**: From Gateway List +1. Navigate to `/admin/gateways` +2. Find the gateway you want to test +3. Click the blue **Test** button + +**Option B**: From Gateway Details +1. Navigate to `/admin/gateways/:id` +2. Scroll to bottom +3. Click **Test Gateway** button + +### Step 2: Check Connection Status + +The page loads with automatic status check: + +1. **Wait for status**: Shows loading spinner +2. **View result**: Green (online) or Red (offline) +3. **Refresh if needed**: Click **Refresh Status** button + +**Connection Check Details**: +- Verifies `last_heartbeat_at` timestamp +- Must be within 2 minutes to be "online" +- Shows exact time of last heartbeat +- Displays human-readable time ago + +### Step 3: Send Test SMS + +1. **Enter phone number**: + - Include country code (e.g., `+959123456789`) + - Required field + - Validated on submission + +2. **Enter message**: + - Default test message provided + - Customizable content + - Character counter updates live + - Warns if over 160 chars + +3. **Click "Send Test SMS"**: + - Button shows spinner: "Sending..." + - Waits for response + - Displays result + +4. **View result**: + +**Success (Green)**: +``` +┌──────────────────────────────────────┐ +│ ✅ Test SMS Sent Successfully! │ +│ Test SMS queued for sending │ +│ Message ID: msg_abc123... │ +│ Status: pending │ +└──────────────────────────────────────┘ +``` + +**Error (Red)**: +``` +┌──────────────────────────────────────┐ +│ ❌ Failed to Send Test SMS │ +│ Phone number is not valid │ +└──────────────────────────────────────┘ +``` + +### Step 4: Verify in Logs + +1. Navigate to `/admin/logs` +2. Look for test message: + - Message ID from success response + - Phone number you entered + - Status: pending → sent → delivered +3. Filter by gateway to see only this gateway's messages + +## API Endpoints + +### Check Connection + +**Endpoint**: `POST /admin/gateways/:id/check_connection` + +**Response (Online)**: +```json +{ + "status": "success", + "message": "Gateway is online", + "last_heartbeat": "2025-10-20T13:45:30.000Z", + "time_ago": "1 minute" +} +``` + +**Response (Offline)**: +```json +{ + "status": "error", + "message": "Gateway is offline", + "last_heartbeat": "2025-10-20T08:30:15.000Z", + "time_ago": "5 hours" +} +``` + +### Send Test SMS + +**Endpoint**: `POST /admin/gateways/:id/send_test_sms` + +**Request**: +```json +{ + "phone_number": "+959123456789", + "message_body": "This is a test message" +} +``` + +**Response (Success)**: +```json +{ + "status": "success", + "message": "Test SMS queued for sending", + "message_id": "msg_abc123def456...", + "sms_status": "pending" +} +``` + +**Response (Error)**: +```json +{ + "status": "error", + "message": "Phone number and message are required" +} +``` + +## Routes Added + +```ruby +resources :gateways do + member do + get :test # Testing page + post :check_connection # AJAX status check + post :send_test_sms # AJAX send test + post :toggle # Activate/deactivate (existing) + end +end +``` + +**New Routes**: +- `GET /admin/gateways/:id/test` - Testing page +- `POST /admin/gateways/:id/check_connection` - Check status +- `POST /admin/gateways/:id/send_test_sms` - Send test SMS + +## Controller Actions + +### `test` +```ruby +def test + @gateway = Gateway.find(params[:id]) +end +``` +Renders the testing page. + +### `check_connection` +```ruby +def check_connection + @gateway = Gateway.find(params[:id]) + + if @gateway.online? + render json: { + status: "success", + message: "Gateway is online", + last_heartbeat: @gateway.last_heartbeat_at, + time_ago: helpers.time_ago_in_words(@gateway.last_heartbeat_at) + } + else + render json: { + status: "error", + message: "Gateway is offline", + last_heartbeat: @gateway.last_heartbeat_at, + time_ago: @gateway.last_heartbeat_at ? helpers.time_ago_in_words(@gateway.last_heartbeat_at) : "never" + } + end +end +``` + +### `send_test_sms` +```ruby +def send_test_sms + @gateway = Gateway.find(params[:id]) + phone_number = params[:phone_number] + message_body = params[:message_body] + + sms = SmsMessage.create!( + direction: "outbound", + phone_number: phone_number, + message_body: message_body, + gateway: @gateway, + metadata: { test: true, sent_from: "admin_interface" } + ) + + render json: { + status: "success", + message: "Test SMS queued for sending", + message_id: sms.message_id, + sms_status: sms.status + } +end +``` + +## JavaScript Features + +### Auto-load Status + +```javascript +document.addEventListener('DOMContentLoaded', function() { + checkConnection(); // Check on page load +}); +``` + +### Refresh Button + +```javascript +async function checkConnection() { + // Show loading + container.innerHTML = '
...
'; + + // Fetch status + const response = await fetch('/admin/gateways/:id/check_connection', { + method: 'POST', + headers: { + 'X-CSRF-Token': csrfToken + } + }); + + const data = await response.json(); + // Display result +} +``` + +### Character Counter + +```javascript +messageBody.addEventListener('input', updateCharCount); + +function updateCharCount() { + const length = messageBody.value.length; + const remaining = 160 - length; + + if (remaining < 0) { + const parts = Math.ceil(length / 160); + charCount.textContent = `${Math.abs(remaining)} characters over (${parts} parts)`; + charCount.classList.add('text-red-600'); + } else { + charCount.textContent = `${remaining} characters remaining`; + } +} +``` + +### Form Submission + +```javascript +form.addEventListener('submit', async function(e) { + e.preventDefault(); + + // Disable button + submitButton.disabled = true; + submitText.innerHTML = 'Sending...'; + + // Send request + const response = await fetch('/admin/gateways/:id/send_test_sms', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + phone_number: phoneNumber.value, + message_body: messageBody.value + }) + }); + + const data = await response.json(); + // Display result and re-enable button +}); +``` + +## Test Message Metadata + +All test messages include metadata for identification: + +```ruby +{ + test: true, + sent_from: "admin_interface" +} +``` + +**Benefits**: +- Easy to filter test messages in logs +- Distinguish from production messages +- Audit trail of admin testing +- Can be excluded from analytics + +## Troubleshooting + +### Connection Check Fails + +**Symptom**: Can't check gateway status + +**Causes**: +1. Database connection issue +2. Gateway record not found +3. JavaScript error + +**Solutions**: +```bash +# Check Rails logs +tail -f log/development.log + +# Verify gateway exists +bin/rails runner "puts Gateway.find(1).inspect" + +# Check browser console for JavaScript errors +``` + +### Test SMS Not Sending + +**Symptom**: SMS queued but never sent + +**Causes**: +1. Gateway offline +2. Sidekiq not running +3. Redis not running +4. Queue backed up + +**Solutions**: +```bash +# Check gateway status +bin/rails console +> Gateway.find(1).online? + +# Check Sidekiq +ps aux | grep sidekiq + +# Start Sidekiq if needed +bundle exec sidekiq + +# Check Redis +redis-cli ping +``` + +### Invalid Phone Number + +**Symptom**: "Phone number is not valid" error + +**Causes**: +1. Missing country code +2. Invalid format +3. Phonelib validation failed + +**Solutions**: +- Always include country code: `+959123456789` +- Check number format for your country +- Test number in console: +```ruby +Phonelib.parse("+959123456789").valid? +``` + +## Security Considerations + +### Admin Authentication Required + +- All testing endpoints require admin login +- CSRF protection enabled +- Session validation +- No public access + +### Rate Limiting (Recommended) + +Consider adding rate limiting: + +```ruby +# config/initializers/rack_attack.rb +Rack::Attack.throttle('test_sms_per_admin', limit: 10, period: 1.hour) do |req| + if req.path == '/admin/gateways/*/send_test_sms' && req.post? + req.session[:admin_id] + end +end +``` + +### Test Message Limits + +**Best Practices**: +- Limit test messages to prevent abuse +- Log all test SMS sends +- Monitor for unusual patterns +- Alert on excessive testing + +### Phone Number Privacy + +**Considerations**: +- Test messages go to real phone numbers +- Recipients will receive actual SMS +- Use dedicated test numbers +- Don't test with customer numbers + +## Best Practices + +### When to Use Testing + +✅ **Good Use Cases**: +- After gateway registration (verify it works) +- After configuration changes +- Diagnosing offline issues +- Verifying app updates +- Training new staff + +❌ **Avoid**: +- Testing with production phone numbers +- Excessive testing (generates costs) +- Testing offline gateways repeatedly +- Using for regular message sending + +### Test Message Guidelines + +**Recommended Content**: +``` +This is a test message from MySMSAPio admin interface. +Gateway: [Gateway Name] +Date: [Date/Time] +Ignore this message. +``` + +**Avoid**: +- Long messages (keep under 160 chars) +- Multiple consecutive tests +- Testing during peak hours +- Sensitive information in tests + +## Monitoring Test Messages + +### View in Logs + +1. Navigate to `/admin/logs` +2. Filter by: + - Gateway name + - Phone number + - Date range +3. Look for status progression: + - `pending` → `sent` → `delivered` +4. Check error messages if failed + +### Identify Test Messages + +Test messages have: +- `metadata.test = true` +- `metadata.sent_from = "admin_interface"` + +**Query in console**: +```ruby +# Find all test messages +SmsMessage.where("metadata->>'test' = 'true'").count + +# Find recent test messages +SmsMessage.where("metadata->>'test' = 'true'") + .where("created_at > ?", 1.day.ago) + .order(created_at: :desc) +``` + +## Summary + +✅ **Implemented**: Gateway testing via admin interface +✅ **Features**: Connection check + Test SMS sending +✅ **Access**: From gateway list or details page +✅ **Real-time**: AJAX status updates +✅ **Tracking**: Full metadata and logging +✅ **Security**: Admin authentication required + +**Test any gateway easily**: +1. Click "Test" button +2. Check status (auto-loads) +3. Send test SMS +4. View in logs + +Perfect for debugging, verification, and training! 🚀 diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..aa43920 --- /dev/null +++ b/Gemfile @@ -0,0 +1,77 @@ +source "https://rubygems.org" + +# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" +gem "rails", "~> 8.0.3" +# The modern asset pipeline for Rails [https://github.com/rails/propshaft] +gem "propshaft" +# Use postgresql as the database for Active Record +gem "pg", "~> 1.1" +# Use the Puma web server [https://github.com/puma/puma] +gem "puma", ">= 5.0" +# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails] +gem "importmap-rails" +# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev] +gem "turbo-rails" +# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev] +gem "stimulus-rails" +# Build JSON APIs with ease [https://github.com/rails/jbuilder] +gem "jbuilder" + +# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] +gem "bcrypt", "~> 3.1.7" + +# SMS Gateway API Dependencies +gem "redis", "~> 5.0" +gem "sidekiq", "~> 7.0" +gem "sidekiq-cron", "~> 1.12" +gem "jwt", "~> 2.7" +gem "rack-cors" +gem "phonelib" +gem "rotp" +gem "httparty" +gem "pagy", "~> 6.0" +gem "rqrcode", "~> 2.0" + +# Windows does not include zoneinfo files, so bundle the tzinfo-data gem +gem "tzinfo-data", platforms: %i[ windows jruby ] + +# Use the database-backed adapters for Rails.cache, Active Job, and Action Cable +gem "solid_cache" +gem "solid_queue" +gem "solid_cable" + +# Reduces boot times through caching; required in config/boot.rb +gem "bootsnap", require: false + +# Deploy this application anywhere as a Docker container [https://kamal-deploy.org] +gem "kamal", require: false + +# Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/] +gem "thruster", require: false + +# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] +# gem "image_processing", "~> 1.2" + +group :development, :test do + # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem + gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" + + # Static analysis for security vulnerabilities [https://brakemanscanner.org/] + gem "brakeman", require: false + + # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] + gem "rubocop-rails-omakase", require: false +end + +group :development do + # Use console on exceptions pages [https://github.com/rails/web-console] + gem "web-console" +end + +group :test do + # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] + gem "capybara" + gem "selenium-webdriver" +end + +gem "tailwindcss-rails", "~> 4.3" diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..9757805 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,447 @@ +GEM + remote: https://rubygems.org/ + specs: + actioncable (8.0.3) + actionpack (= 8.0.3) + activesupport (= 8.0.3) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (8.0.3) + actionpack (= 8.0.3) + activejob (= 8.0.3) + activerecord (= 8.0.3) + activestorage (= 8.0.3) + activesupport (= 8.0.3) + mail (>= 2.8.0) + actionmailer (8.0.3) + actionpack (= 8.0.3) + actionview (= 8.0.3) + activejob (= 8.0.3) + activesupport (= 8.0.3) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.0.3) + actionview (= 8.0.3) + activesupport (= 8.0.3) + nokogiri (>= 1.8.5) + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (8.0.3) + actionpack (= 8.0.3) + activerecord (= 8.0.3) + activestorage (= 8.0.3) + activesupport (= 8.0.3) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (8.0.3) + activesupport (= 8.0.3) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (8.0.3) + activesupport (= 8.0.3) + globalid (>= 0.3.6) + activemodel (8.0.3) + activesupport (= 8.0.3) + activerecord (8.0.3) + activemodel (= 8.0.3) + activesupport (= 8.0.3) + timeout (>= 0.4.0) + activestorage (8.0.3) + actionpack (= 8.0.3) + activejob (= 8.0.3) + activerecord (= 8.0.3) + activesupport (= 8.0.3) + marcel (~> 1.0) + activesupport (8.0.3) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + ast (2.4.3) + base64 (0.3.0) + bcrypt (3.1.20) + bcrypt_pbkdf (1.1.1) + benchmark (0.4.1) + bigdecimal (3.3.1) + bindex (0.8.1) + bootsnap (1.18.6) + msgpack (~> 1.2) + brakeman (7.1.0) + racc + builder (3.3.0) + capybara (3.40.0) + addressable + matrix + mini_mime (>= 0.1.3) + nokogiri (~> 1.11) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + regexp_parser (>= 1.5, < 3.0) + xpath (~> 3.2) + chunky_png (1.4.0) + concurrent-ruby (1.3.5) + connection_pool (2.5.4) + crass (1.0.6) + csv (3.3.5) + date (3.4.1) + debug (1.11.0) + irb (~> 1.10) + reline (>= 0.3.8) + dotenv (3.1.8) + drb (2.2.3) + ed25519 (1.4.0) + erb (5.1.1) + erubi (1.13.1) + et-orbi (1.4.0) + tzinfo + fugit (1.11.2) + et-orbi (~> 1, >= 1.2.11) + raabro (~> 1.4) + globalid (1.3.0) + activesupport (>= 6.1) + httparty (0.23.2) + csv + mini_mime (>= 1.0.0) + multi_xml (>= 0.5.2) + i18n (1.14.7) + concurrent-ruby (~> 1.0) + importmap-rails (2.2.2) + actionpack (>= 6.0.0) + activesupport (>= 6.0.0) + railties (>= 6.0.0) + io-console (0.8.1) + irb (1.15.2) + pp (>= 0.6.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + jbuilder (2.14.1) + actionview (>= 7.0.0) + activesupport (>= 7.0.0) + json (2.15.1) + jwt (2.10.2) + base64 + kamal (2.7.0) + activesupport (>= 7.0) + base64 (~> 0.2) + bcrypt_pbkdf (~> 1.0) + concurrent-ruby (~> 1.2) + dotenv (~> 3.1) + ed25519 (~> 1.4) + net-ssh (~> 7.3) + sshkit (>= 1.23.0, < 2.0) + thor (~> 1.3) + zeitwerk (>= 2.6.18, < 3.0) + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + logger (1.7.0) + loofah (2.24.1) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.8.1) + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.1.0) + matrix (0.4.3) + mini_mime (1.1.5) + minitest (5.26.0) + msgpack (1.8.0) + multi_xml (0.7.2) + bigdecimal (~> 3.1) + net-imap (0.5.12) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-scp (4.1.0) + net-ssh (>= 2.6.5, < 8.0.0) + net-sftp (4.0.0) + net-ssh (>= 5.0.0, < 8.0.0) + net-smtp (0.5.1) + net-protocol + net-ssh (7.3.0) + nio4r (2.7.4) + nokogiri (1.18.10-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.10-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.18.10-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.10-arm-linux-musl) + racc (~> 1.4) + nokogiri (1.18.10-arm64-darwin) + racc (~> 1.4) + nokogiri (1.18.10-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.10-x86_64-linux-musl) + racc (~> 1.4) + ostruct (0.6.3) + pagy (6.5.0) + parallel (1.27.0) + parser (3.3.9.0) + ast (~> 2.4.1) + racc + pg (1.6.2) + pg (1.6.2-aarch64-linux) + pg (1.6.2-aarch64-linux-musl) + pg (1.6.2-arm64-darwin) + pg (1.6.2-x86_64-linux) + pg (1.6.2-x86_64-linux-musl) + phonelib (0.10.12) + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + prism (1.6.0) + propshaft (1.3.1) + actionpack (>= 7.0.0) + activesupport (>= 7.0.0) + rack + psych (5.2.6) + date + stringio + public_suffix (6.0.2) + puma (7.1.0) + nio4r (~> 2.0) + raabro (1.4.0) + racc (1.8.1) + rack (3.2.3) + rack-cors (3.0.0) + logger + rack (>= 3.0.14) + rack-session (2.1.1) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.2.1) + rack (>= 3) + rails (8.0.3) + actioncable (= 8.0.3) + actionmailbox (= 8.0.3) + actionmailer (= 8.0.3) + actionpack (= 8.0.3) + actiontext (= 8.0.3) + actionview (= 8.0.3) + activejob (= 8.0.3) + activemodel (= 8.0.3) + activerecord (= 8.0.3) + activestorage (= 8.0.3) + activesupport (= 8.0.3) + bundler (>= 1.15.0) + railties (= 8.0.3) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.6.2) + loofah (~> 2.21) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + railties (8.0.3) + actionpack (= 8.0.3) + activesupport (= 8.0.3) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) + zeitwerk (~> 2.6) + rainbow (3.1.1) + rake (13.3.0) + rdoc (6.15.0) + erb + psych (>= 4.0.0) + tsort + redis (5.4.1) + redis-client (>= 0.22.0) + redis-client (0.26.1) + connection_pool + regexp_parser (2.11.3) + reline (0.6.2) + io-console (~> 0.5) + rexml (3.4.4) + rotp (6.3.0) + rqrcode (2.2.0) + chunky_png (~> 1.0) + rqrcode_core (~> 1.0) + rqrcode_core (1.2.0) + rubocop (1.81.1) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.47.1, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.47.1) + parser (>= 3.3.7.2) + prism (~> 1.4) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.33.4) + activesupport (>= 4.2.0) + lint_roller (~> 1.1) + rack (>= 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rails-omakase (1.1.0) + rubocop (>= 1.72) + rubocop-performance (>= 1.24) + rubocop-rails (>= 2.30) + ruby-progressbar (1.13.0) + rubyzip (3.2.0) + securerandom (0.4.1) + selenium-webdriver (4.37.0) + base64 (~> 0.2) + logger (~> 1.4) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 4.0) + websocket (~> 1.0) + sidekiq (7.3.9) + base64 + connection_pool (>= 2.3.0) + logger + rack (>= 2.2.4) + redis-client (>= 0.22.2) + sidekiq-cron (1.12.0) + fugit (~> 1.8) + globalid (>= 1.0.1) + sidekiq (>= 6) + solid_cable (3.0.12) + actioncable (>= 7.2) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_cache (1.0.8) + activejob (>= 7.2) + activerecord (>= 7.2) + railties (>= 7.2) + solid_queue (1.2.1) + activejob (>= 7.1) + activerecord (>= 7.1) + concurrent-ruby (>= 1.3.1) + fugit (~> 1.11.0) + railties (>= 7.1) + thor (>= 1.3.1) + sshkit (1.24.0) + base64 + logger + net-scp (>= 1.1.2) + net-sftp (>= 2.1.2) + net-ssh (>= 2.8.0) + ostruct + stimulus-rails (1.3.4) + railties (>= 6.0.0) + stringio (3.1.7) + tailwindcss-rails (4.3.0) + railties (>= 7.0.0) + tailwindcss-ruby (~> 4.0) + tailwindcss-ruby (4.1.13) + tailwindcss-ruby (4.1.13-aarch64-linux-gnu) + tailwindcss-ruby (4.1.13-aarch64-linux-musl) + tailwindcss-ruby (4.1.13-arm64-darwin) + tailwindcss-ruby (4.1.13-x86_64-linux-gnu) + tailwindcss-ruby (4.1.13-x86_64-linux-musl) + thor (1.4.0) + thruster (0.1.15) + thruster (0.1.15-aarch64-linux) + thruster (0.1.15-arm64-darwin) + thruster (0.1.15-x86_64-linux) + timeout (0.4.3) + tsort (0.2.0) + turbo-rails (2.0.17) + actionpack (>= 7.1.0) + railties (>= 7.1.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.1.0) + uri (1.0.4) + useragent (0.16.11) + web-console (4.2.1) + actionview (>= 6.0.0) + activemodel (>= 6.0.0) + bindex (>= 0.4.0) + railties (>= 6.0.0) + websocket (1.2.11) + websocket-driver (0.8.0) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + xpath (3.2.0) + nokogiri (~> 1.8) + zeitwerk (2.7.3) + +PLATFORMS + aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin-24 + x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + bcrypt (~> 3.1.7) + bootsnap + brakeman + capybara + debug + httparty + importmap-rails + jbuilder + jwt (~> 2.7) + kamal + pagy (~> 6.0) + pg (~> 1.1) + phonelib + propshaft + puma (>= 5.0) + rack-cors + rails (~> 8.0.3) + redis (~> 5.0) + rotp + rqrcode (~> 2.0) + rubocop-rails-omakase + selenium-webdriver + sidekiq (~> 7.0) + sidekiq-cron (~> 1.12) + solid_cable + solid_cache + solid_queue + stimulus-rails + tailwindcss-rails (~> 4.3) + thruster + turbo-rails + tzinfo-data + web-console + +BUNDLED WITH + 2.6.9 diff --git a/JSONB_FIXES.md b/JSONB_FIXES.md new file mode 100644 index 0000000..b20499a --- /dev/null +++ b/JSONB_FIXES.md @@ -0,0 +1,326 @@ +# JSONB Field Fixes - Complete Solution + +## Issue + +``` +undefined method 'stringify_keys' for an instance of String +``` + +This error occurred when rendering views that use Rails helpers like `button_to`, `link_to`, or `form_with` that serialize model objects containing JSONB fields. + +## Root Cause + +PostgreSQL JSONB columns can sometimes return inconsistent data types: +- Sometimes returns a Hash (correct) +- Sometimes returns a String (incorrect - causes stringify_keys error) +- Sometimes returns nil (causes nil errors) + +This happens when Rails tries to serialize model objects for URL generation or form helpers. + +## Solution Applied + +Added explicit JSONB attribute handling to ALL models with JSONB fields to ensure they always return Hash objects. + +--- + +## Models Fixed + +### 1. ApiKey Model - `permissions` field + +**File**: `app/models/api_key.rb` + +**JSONB Field**: `permissions` (stores API key permissions like send_sms, receive_sms, etc.) + +**Fix Applied**: +```ruby +class ApiKey < ApplicationRecord + # Normalize permissions to always be a Hash + attribute :permissions, :jsonb, default: {} + + before_validation :ensure_permissions_is_hash + + # ... rest of the model ... + + private + + def ensure_permissions_is_hash + self.permissions = {} if permissions.nil? + self.permissions = {} unless permissions.is_a?(Hash) + end +end +``` + +--- + +### 2. Gateway Model - `metadata` field + +**File**: `app/models/gateway.rb` + +**JSONB Field**: `metadata` (stores additional gateway configuration and metadata) + +**Fix Applied**: +```ruby +class Gateway < ApplicationRecord + # Normalize metadata to always be a Hash + attribute :metadata, :jsonb, default: {} + + before_validation :ensure_metadata_is_hash + + # ... rest of the model ... + + private + + def ensure_metadata_is_hash + self.metadata = {} if metadata.nil? + self.metadata = {} unless metadata.is_a?(Hash) + end +end +``` + +--- + +### 3. OtpCode Model - `metadata` field + +**File**: `app/models/otp_code.rb` + +**JSONB Field**: `metadata` (stores OTP-related metadata like device info, IP address context, etc.) + +**Fix Applied**: +```ruby +class OtpCode < ApplicationRecord + # Normalize metadata to always be a Hash + attribute :metadata, :jsonb, default: {} + + before_validation :ensure_metadata_is_hash + + # ... rest of the model ... + + private + + def ensure_metadata_is_hash + self.metadata = {} if metadata.nil? + self.metadata = {} unless metadata.is_a?(Hash) + end +end +``` + +--- + +### 4. SmsMessage Model - `metadata` field + +**File**: `app/models/sms_message.rb` + +**JSONB Field**: `metadata` (stores message metadata like delivery reports, carrier info, etc.) + +**Fix Applied**: +```ruby +class SmsMessage < ApplicationRecord + # Normalize metadata to always be a Hash + attribute :metadata, :jsonb, default: {} + + before_validation :ensure_metadata_is_hash + + # ... rest of the model ... + + private + + def ensure_metadata_is_hash + self.metadata = {} if metadata.nil? + self.metadata = {} unless metadata.is_a?(Hash) + end +end +``` + +--- + +## Database Schema Reference + +All JSONB columns are properly defined in the schema with default values: + +```ruby +# db/schema.rb + +create_table "api_keys" do |t| + # ... + t.jsonb "permissions", default: {} + # ... +end + +create_table "gateways" do |t| + # ... + t.jsonb "metadata", default: {} + # ... +end + +create_table "otp_codes" do |t| + # ... + t.jsonb "metadata", default: {} + # ... +end + +create_table "sms_messages" do |t| + # ... + t.jsonb "metadata", default: {} + # ... +end +``` + +--- + +## How The Fix Works + +### 1. Explicit Attribute Declaration +```ruby +attribute :permissions, :jsonb, default: {} +``` +This tells ActiveRecord to explicitly treat the column as JSONB and always default to an empty Hash. + +### 2. Before Validation Callback +```ruby +before_validation :ensure_permissions_is_hash +``` +Runs before every validation, ensuring the field is always a Hash before Rails processes it. + +### 3. Hash Normalization Method +```ruby +def ensure_permissions_is_hash + self.permissions = {} if permissions.nil? + self.permissions = {} unless permissions.is_a?(Hash) +end +``` +- Converts nil to {} +- Converts any non-Hash value to {} +- Leaves Hash values unchanged + +--- + +## Why This Prevents stringify_keys Error + +When Rails helpers like `button_to` serialize model objects: + +**Before Fix**: +```ruby +api_key.permissions # Sometimes returns String "{"send_sms": true}" +# Rails tries to call .stringify_keys on String +# ERROR: undefined method 'stringify_keys' for String +``` + +**After Fix**: +```ruby +api_key.permissions # Always returns Hash {"send_sms" => true} +# Rails successfully calls .stringify_keys on Hash +# SUCCESS: No error +``` + +--- + +## View Safety Measures + +In addition to model fixes, views also have defensive coding: + +**app/views/admin/api_keys/index.html.erb**: +```erb +<% perms = api_key.permissions || {} %> +<% perms = {} unless perms.is_a?(Hash) %> +<% if perms.any? %> + <% perms.select { |_, v| v }.keys.each do |perm| %> + <%= perm.to_s.humanize %> + <% end %> +<% else %> + None +<% end %> +``` + +This provides double protection: +1. Model ensures JSONB field is always Hash +2. View verifies and provides fallback + +--- + +## Testing The Fix + +### Console Test +```bash +bin/rails console +``` + +```ruby +# Test ApiKey permissions +api_key = ApiKey.first +api_key.permissions.class # => Hash +api_key.can?("send_sms") # => true or false + +# Test Gateway metadata +gateway = Gateway.first +gateway.metadata.class # => Hash +gateway.metadata["foo"] = "bar" +gateway.save! # Should work without errors + +# Test OtpCode metadata +otp = OtpCode.first +otp.metadata.class # => Hash + +# Test SmsMessage metadata +msg = SmsMessage.first +msg.metadata.class # => Hash +``` + +### Server Test +```bash +# Start server +bin/dev + +# Visit admin interface +# http://localhost:3000/admin/login +# Login and navigate to API Keys +# Should load without stringify_keys error +``` + +--- + +## Benefits of This Approach + +✅ **Consistent**: All JSONB fields behave the same way across all models +✅ **Safe**: Handles nil, String, and other edge cases gracefully +✅ **Performance**: Minimal overhead (callback only runs on save/update) +✅ **Rails-native**: Uses Rails attribute API, not monkey-patching +✅ **Future-proof**: Works with all Rails helpers and serializers +✅ **Maintainable**: Clear, documented pattern that's easy to understand + +--- + +## Adding JSONB Fields in the Future + +When adding new JSONB fields to any model, follow this pattern: + +```ruby +class MyModel < ApplicationRecord + # 1. Declare the attribute + attribute :my_jsonb_field, :jsonb, default: {} + + # 2. Add before_validation callback + before_validation :ensure_my_jsonb_field_is_hash + + # ... rest of model code ... + + private + + # 3. Add normalization method + def ensure_my_jsonb_field_is_hash + self.my_jsonb_field = {} if my_jsonb_field.nil? + self.my_jsonb_field = {} unless my_jsonb_field.is_a?(Hash) + end +end +``` + +--- + +## Summary + +✅ **Fixed Models**: ApiKey, Gateway, OtpCode, SmsMessage +✅ **Fixed Fields**: permissions (ApiKey), metadata (Gateway, OtpCode, SmsMessage) +✅ **Error Resolved**: stringify_keys error when using button_to, form_with, etc. +✅ **Approach**: Explicit attribute declaration + before_validation normalization +✅ **Safety**: Double protection (model + view defensive coding) + +All JSONB fields now consistently return Hash objects, preventing serialization errors throughout the Rails application! diff --git a/NAMESPACE_FIX.md b/NAMESPACE_FIX.md new file mode 100644 index 0000000..eba4f3e --- /dev/null +++ b/NAMESPACE_FIX.md @@ -0,0 +1,87 @@ +# Namespace Conflict Fix + +## Issue +There was a naming conflict between the `Admin` model class and the `Admin` module namespace used by the admin controllers. + +``` +TypeError (Admin is not a module +/app/models/admin.rb:1: previous definition of Admin was here) +``` + +## Solution +Renamed the model from `Admin` to `AdminUser` to avoid the namespace conflict. + +## Changes Made + +### 1. Database Migration +- Created migration: `20251020031401_rename_admins_to_admin_users.rb` +- Renamed table: `admins` → `admin_users` + +### 2. Model Renamed +- File: `app/models/admin.rb` → `app/models/admin_user.rb` +- Class: `Admin` → `AdminUser` + +### 3. Controllers Updated +- `app/controllers/admin/base_controller.rb`: Updated `current_admin` method +- `app/controllers/admin/sessions_controller.rb`: Updated authentication logic + +### 4. Seeds Updated +- `db/seeds.rb`: Changed `Admin` to `AdminUser` + +### 5. Tests Updated +- File: `test/models/admin_test.rb` → `test/models/admin_user_test.rb` +- Class: `AdminTest` → `AdminUserTest` +- Fixtures: `test/fixtures/admins.yml` → `test/fixtures/admin_users.yml` + +## No View Changes Required +The views remain unchanged because they use helper methods (`current_admin`, `logged_in?`) that abstract away the model name. + +## Migration Command +```bash +bin/rails db:migrate +``` + +## Database Structure +```ruby +# Before +create_table "admins" do |t| + t.string :email, null: false + t.string :password_digest, null: false + t.string :name, null: false + t.datetime :last_login_at + t.timestamps +end + +# After +create_table "admin_users" do |t| + t.string :email, null: false + t.string :password_digest, null: false + t.string :name, null: false + t.datetime :last_login_at + t.timestamps +end +``` + +## Login Credentials +Unchanged - still: +``` +Email: admin@example.com +Password: password123 +``` + +## API Usage +No changes to the admin interface or API endpoints. Everything works the same from a user perspective. + +## Technical Notes +- Rails namespaces (modules) take precedence over class names +- Having `module Admin` and `class Admin` in the same project causes conflicts +- The solution is to rename either the module or the class +- We chose to rename the model since it's less invasive than renaming all controllers + +## Verification +Run this to verify everything works: +```bash +bin/rails runner "puts AdminUser.first.email" +``` + +Should output: `admin@example.com` diff --git a/PERMISSIONS_FIX.md b/PERMISSIONS_FIX.md new file mode 100644 index 0000000..590cdd1 --- /dev/null +++ b/PERMISSIONS_FIX.md @@ -0,0 +1,183 @@ +# Permissions Field Fix + +## Issue +``` +undefined method 'stringify_keys' for an instance of String +``` + +This error occurred in the API Keys index view when trying to display permissions. + +## Root Cause + +The `permissions` field in the `api_keys` table is a `jsonb` column (PostgreSQL native JSON type). Rails handles this automatically, but the code was: +1. Not properly handling the `permissions` attribute +2. Not providing safe fallbacks for nil or invalid data + +## Solution Applied + +### 1. Updated ApiKey Model + +**File:** `app/models/api_key.rb` + +Added a safe `permissions` method that: +- Returns empty hash if permissions is nil +- Returns the value if it's already a Hash +- Parses JSON if it's a String +- Returns empty hash on any error + +```ruby +# Ensure permissions is always a hash +def permissions + value = read_attribute(:permissions) + return {} if value.nil? + return value if value.is_a?(Hash) + return JSON.parse(value) if value.is_a?(String) + {} +rescue JSON::ParserError + {} +end +``` + +### 2. Updated Views + +**Files:** +- `app/views/admin/api_keys/index.html.erb` +- `app/views/admin/api_keys/show.html.erb` + +Added defensive code to handle edge cases: + +```erb +<% perms = api_key.permissions || {} %> +<% perms = {} unless perms.is_a?(Hash) %> +<% if perms.any? %> + <% perms.select { |_, v| v }.keys.each do |perm| %> + <%= perm.to_s.humanize %> + <% end %> +<% else %> + None +<% end %> +``` + +## Why This Works + +### PostgreSQL JSONB Support +- Rails 5+ has native support for PostgreSQL JSONB columns +- No need for `serialize` declaration +- Data is stored and retrieved as Hash automatically +- But we need to handle edge cases + +### Safe Accessor Method +- The custom `permissions` method ensures we always get a Hash +- Handles legacy data or corrupted entries +- Provides sensible defaults + +### View Defensive Coding +- Checks for nil before using +- Verifies it's a Hash +- Gracefully degrades to "None" if empty + +## Database Schema + +The permissions column is properly defined: + +```ruby +create_table "api_keys" do |t| + # ... + t.jsonb "permissions", default: {} + # ... +end +``` + +**Key points:** +- Type: `jsonb` (not `json` or `text`) +- Default: `{}` (empty hash) +- No serialization needed + +## Verification + +Test that permissions work correctly: + +```bash +bin/rails runner " + api_key = ApiKey.first + puts 'Permissions class: ' + api_key.permissions.class.to_s + puts 'Permissions value: ' + api_key.permissions.inspect + puts 'Can check permission: ' + api_key.can?('send_sms').to_s +" +``` + +Should output: +``` +Permissions class: Hash +Permissions value: {"send_sms"=>true, "receive_sms"=>true, ...} +Can check permission: true +``` + +## Related Code + +### Creating API Keys +The create action already passes a hash: + +```ruby +permissions = {} +permissions["send_sms"] = params[:api_key][:send_sms] == "1" +permissions["receive_sms"] = params[:api_key][:receive_sms] == "1" +permissions["manage_gateways"] = params[:api_key][:manage_gateways] == "1" + +ApiKey.generate!( + name: params[:api_key][:name], + permissions: permissions, + expires_at: expires_at +) +``` + +### Checking Permissions +The `can?` method works with our safe accessor: + +```ruby +def can?(permission) + permissions.fetch(permission.to_s, false) +end +``` + +Usage: +```ruby +api_key.can?("send_sms") # => true or false +``` + +## Testing + +### Manual Test +1. Start server: `bin/dev` +2. Visit: http://localhost:3000/admin/api_keys +3. Should see permissions displayed as badges +4. Create new API key +5. Check permissions are saved and displayed + +### Console Test +```ruby +# In rails console +api_key = ApiKey.first + +# Should return Hash +api_key.permissions +# => {"send_sms"=>true, "receive_sms"=>true} + +# Should work +api_key.can?("send_sms") +# => true + +# Should handle missing permissions +api_key.can?("nonexistent") +# => false +``` + +## Summary + +✅ **Fixed**: Permissions now always return a Hash +✅ **Fixed**: Views handle nil/invalid permissions gracefully +✅ **Improved**: Added defensive coding throughout +✅ **Maintained**: PostgreSQL native JSONB support +✅ **Tested**: All API keys work correctly + +The admin interface can now safely display API keys and their permissions without errors! diff --git a/Procfile.dev b/Procfile.dev new file mode 100644 index 0000000..da151fe --- /dev/null +++ b/Procfile.dev @@ -0,0 +1,2 @@ +web: bin/rails server +css: bin/rails tailwindcss:watch diff --git a/QR_CODE_SETUP.md b/QR_CODE_SETUP.md new file mode 100644 index 0000000..2eaa6ff --- /dev/null +++ b/QR_CODE_SETUP.md @@ -0,0 +1,449 @@ +# QR Code Gateway Setup + +## Overview + +The gateway registration now includes QR code generation for quick and easy Android app configuration. When a new gateway is created, the system automatically generates a QR code containing all necessary configuration data. + +## What's Included in the QR Code + +The QR code contains a JSON payload with: + +```json +{ + "api_key": "gw_live_a6e2b250dade8f6501256a8717723fc3f8ab7d4e7cb26aad470d65ee8478a82c", + "api_base_url": "http://localhost:3000", + "websocket_url": "ws://localhost:3000/cable", + "version": "1.0" +} +``` + +### Fields Explained + +| Field | Description | Example | +|-------|-------------|---------| +| `api_key` | Gateway authentication key | `gw_live_...` (64 chars) | +| `api_base_url` | Base URL for API endpoints | `http://localhost:3000` or `https://api.example.com` | +| `websocket_url` | WebSocket connection URL | `ws://localhost:3000/cable` or `wss://api.example.com/cable` | +| `version` | Configuration format version | `1.0` | + +## Features + +### 1. QR Code Display + +**Location**: Gateway creation success page (`/admin/gateways/:id` after creation) + +**Visual Features**: +- High-quality SVG QR code +- Error correction level: High (L=H) +- White background with border +- Centered display +- Info badge explaining contents + +### 2. Manual Configuration Fallback + +If QR scanning is unavailable, the page also displays: +- API Base URL (with individual copy button) +- WebSocket URL (with individual copy button) +- API Key (with individual copy button) +- "Copy All" button to copy all three fields at once + +### 3. Copy to Clipboard Functions + +**Individual Field Copy**: +```javascript +copyField('api-base-url') // Copies just the API base URL +copyField('ws-url') // Copies just the WebSocket URL +copyField('api-key') // Copies just the API key +``` + +**Copy All Configuration**: +```javascript +copyAllConfig() // Copies all fields as formatted text +``` + +Output format: +``` +API Base URL: http://localhost:3000 +WebSocket URL: ws://localhost:3000/cable +API Key: gw_live_a6e2b250dade8f6501256a8717723fc3f8ab7d4e7cb26aad470d65ee8478a82c +``` + +## Implementation Details + +### Gem Used + +**rqrcode** v2.0+ +- Pure Ruby QR code generator +- No external dependencies +- SVG output support +- High error correction + +Added to `Gemfile`: +```ruby +gem "rqrcode", "~> 2.0" +``` + +### Controller Method + +**File**: `app/controllers/admin/gateways_controller.rb` + +```ruby +def generate_qr_code_data(api_key) + require "rqrcode" + + # Determine URLs based on request + base_url = request.base_url + ws_url = request.base_url.sub(/^http/, "ws") + "/cable" + + # Create JSON configuration + config_data = { + api_key: api_key, + api_base_url: base_url, + websocket_url: ws_url, + version: "1.0" + }.to_json + + # Generate QR code with high error correction + qr = RQRCode::QRCode.new(config_data, level: :h) + + # Return as SVG string + qr.as_svg( + offset: 0, + color: "000", + shape_rendering: "crispEdges", + module_size: 4, + standalone: true, + use_path: true + ) +end +``` + +### View Integration + +**File**: `app/views/admin/gateways/show.html.erb` + +The QR code is displayed using: +```erb +
+ <%= @qr_code_data.html_safe %> +
+``` + +## URL Detection + +The system automatically detects the correct URLs based on the request: + +### Development +- Base URL: `http://localhost:3000` +- WebSocket URL: `ws://localhost:3000/cable` + +### Production (HTTP) +- Base URL: `http://api.example.com` +- WebSocket URL: `ws://api.example.com/cable` + +### Production (HTTPS) - Recommended +- Base URL: `https://api.example.com` +- WebSocket URL: `wss://api.example.com/cable` + +**Note**: WebSocket URL automatically changes from `http` to `ws` and `https` to `wss`. + +## Android App Integration + +### QR Code Scanning Flow + +1. **User opens Android SMS Gateway app** +2. **Taps "Scan QR Code" or similar option** +3. **Camera opens with QR scanner** +4. **Scans the QR code from admin interface** +5. **App parses JSON configuration** +6. **All fields auto-populated**: + - API Base URL field + - WebSocket URL field + - API Key field +7. **User taps "Save" or "Connect"** +8. **App connects to server** +9. **Gateway appears as "Online" in admin interface** + +### Expected Android App Code + +The Android app should: + +1. **Scan QR Code**: +```kotlin +// Using ML Kit or ZXing library +val result = qrCodeScanner.scan() +val jsonString = result.text +``` + +2. **Parse JSON**: +```kotlin +val config = JSONObject(jsonString) +val apiKey = config.getString("api_key") +val apiBaseUrl = config.getString("api_base_url") +val websocketUrl = config.getString("websocket_url") +val version = config.getString("version") +``` + +3. **Validate Version**: +```kotlin +if (version != "1.0") { + showError("Unsupported configuration version") + return +} +``` + +4. **Save Configuration**: +```kotlin +sharedPreferences.edit().apply { + putString("api_key", apiKey) + putString("api_base_url", apiBaseUrl) + putString("websocket_url", websocketUrl) + apply() +} +``` + +5. **Connect to Server**: +```kotlin +// Connect to WebSocket +webSocketClient.connect(websocketUrl, apiKey) + +// Test API connection +apiClient.setBaseUrl(apiBaseUrl) +apiClient.setAuthToken(apiKey) +apiClient.sendHeartbeat() +``` + +## Security Considerations + +### QR Code Security + +✅ **Secure**: +- QR code displayed only once after creation +- Requires admin authentication to view +- Session-based display (not in URL) +- Page cannot be refreshed to see QR code again + +⚠️ **Warning**: +- Anyone with camera access to the screen can scan the QR code +- QR code contains full API key in plaintext JSON +- Suitable for secure environments only + +### Best Practices + +1. **Display Environment**: + - Only display QR code in secure locations + - Ensure no cameras/recording devices nearby + - Clear screen after scanning + +2. **Network Security**: + - Use HTTPS/WSS in production (`config.force_ssl = true`) + - Never use HTTP/WS in production + - Implement rate limiting on WebSocket connections + +3. **Key Management**: + - QR code shown only once during gateway creation + - If compromised, deactivate gateway and create new one + - Regularly audit active gateways + +4. **Mobile App Security**: + - Store configuration in encrypted SharedPreferences + - Use Android Keystore for API key storage + - Implement certificate pinning for API calls + - Validate SSL certificates for WebSocket connections + +## Troubleshooting + +### QR Code Not Displaying + +**Check**: +1. `rqrcode` gem installed: `bundle show rqrcode` +2. Controller generates QR code: Check `@qr_code_data` in view +3. Browser console for JavaScript errors +4. View source - SVG should be present + +**Fix**: +```bash +bundle install +bin/rails restart +``` + +### QR Code Too Complex to Scan + +**Symptom**: QR scanner can't read the code + +**Cause**: JSON payload too long (rare, but possible with very long URLs) + +**Solution**: +- Use shorter domain names +- Reduce module_size in controller (current: 4) +- Lower error correction level (current: :h, try :m or :l) + +### Wrong URLs in QR Code + +**Symptom**: QR code contains `localhost` in production + +**Cause**: `request.base_url` not detecting correctly + +**Fix**: Set environment variables in production +```bash +# .env or config +RAILS_FORCE_SSL=true +RAILS_RELATIVE_URL_ROOT=https://api.example.com +``` + +Or override in controller: +```ruby +base_url = ENV['API_BASE_URL'] || request.base_url +``` + +### Android App Can't Parse QR Code + +**Symptom**: App shows "Invalid QR code" error + +**Causes**: +1. QR code scanner library issue +2. JSON parsing error +3. Wrong configuration version + +**Debug**: +```kotlin +try { + val json = JSONObject(qrCodeText) + Log.d("QR", "API Key: ${json.getString("api_key")}") + Log.d("QR", "Base URL: ${json.getString("api_base_url")}") + Log.d("QR", "WS URL: ${json.getString("websocket_url")}") +} catch (e: Exception) { + Log.e("QR", "Parse error: ${e.message}") +} +``` + +## Testing + +### Manual QR Code Test + +1. **Create Test Gateway**: +```bash +bin/rails console +``` + +```ruby +gateway = Gateway.new( + device_id: "test-qr-001", + name: "QR Test Gateway", + priority: 1, + status: "offline" +) +raw_key = gateway.generate_api_key! +puts "Gateway ID: #{gateway.id}" +puts "Raw Key: #{raw_key}" +``` + +2. **Navigate to Success Page**: + - Visit: `http://localhost:3000/admin/gateways/new` + - Fill form and submit + - Should redirect to gateway show page with QR code + +3. **Test QR Code**: + - Open QR code scanner app on phone + - Scan the displayed QR code + - Verify JSON payload contains all fields + +4. **Test Copy Buttons**: + - Click individual copy buttons (API URL, WS URL, API Key) + - Verify green checkmark feedback + - Click "Copy All" button + - Paste in text editor - verify format + +### Automated Test + +```ruby +# test/controllers/admin/gateways_controller_test.rb +test "should generate QR code on gateway creation" do + post admin_gateways_url, params: { + gateway: { + device_id: "test-001", + name: "Test Gateway", + priority: 5 + } + } + + assert_response :redirect + + gateway = Gateway.last + get admin_gateway_url(gateway) + + assert_response :success + assert_select 'svg' # QR code should be present as SVG +end +``` + +## Configuration Examples + +### Local Development +```json +{ + "api_key": "gw_live_abc123...", + "api_base_url": "http://localhost:3000", + "websocket_url": "ws://localhost:3000/cable", + "version": "1.0" +} +``` + +### Staging Environment +```json +{ + "api_key": "gw_live_def456...", + "api_base_url": "https://staging-api.example.com", + "websocket_url": "wss://staging-api.example.com/cable", + "version": "1.0" +} +``` + +### Production Environment +```json +{ + "api_key": "gw_live_ghi789...", + "api_base_url": "https://api.example.com", + "websocket_url": "wss://api.example.com/cable", + "version": "1.0" +} +``` + +## Future Enhancements + +### Possible Improvements + +1. **Download QR Code**: + - Add "Download QR Code" button + - Generate PNG image + - Allow saving for later + +2. **Email QR Code**: + - Send QR code via email + - Secure time-limited link + - Auto-expires after 1 hour + +3. **Multiple QR Code Formats**: + - Different sizes (small, medium, large) + - Different error correction levels + - PNG, SVG, PDF options + +4. **Configuration Presets**: + - Save common configurations + - Apply preset to multiple gateways + - Template system + +5. **Advanced Security**: + - Encrypted QR code payload + - Time-limited configuration URLs + - Two-factor gateway activation + +## Summary + +✅ **Implemented**: QR code generation with all gateway configuration +✅ **Features**: Scan QR code OR manual copy/paste +✅ **Security**: One-time display, session-based, admin-only +✅ **UX**: Copy buttons with visual feedback, clear instructions +✅ **Production Ready**: Automatic URL detection (HTTP/HTTPS/WS/WSS) + +The QR code feature makes gateway setup much faster and reduces configuration errors by eliminating manual typing of long API keys! diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..b23a8ff --- /dev/null +++ b/QUICKSTART.md @@ -0,0 +1,137 @@ +# Quick Start Guide - SMS Gateway API + +This guide will help you get the SMS Gateway API up and running quickly. + +## Prerequisites + +Ensure you have the following installed: +- Ruby 3.4.7 +- PostgreSQL 14+ +- Redis 7+ +- Bundler 2.x + +## Step-by-Step Setup + +### 1. Install Dependencies + +```bash +bundle install +``` + +### 2. Set Up Database + +```bash +# Create databases +bin/rails db:create + +# Run migrations +bin/rails db:migrate + +# Seed sample data (includes API keys and test gateways) +bin/rails db:seed +``` + +**⚠️ IMPORTANT**: After seeding, you'll see API keys printed. **Save these immediately** as they won't be shown again! + +### 3. Start Redis + +If Redis isn't running: + +```bash +redis-server +``` + +### 4. Start Sidekiq (Background Jobs) + +In a separate terminal: + +```bash +bundle exec sidekiq +``` + +### 5. Start Rails Server + +```bash +bin/rails server +``` + +The API will be available at `http://localhost:3000` + +## Quick Test + +### Test the Health Check + +```bash +curl http://localhost:3000/up +``` + +Should return 200 OK. + +### Send a Test SMS + +Replace `YOUR_API_KEY` with the API key from the seed output: + +```bash +curl -X POST http://localhost:3000/api/v1/sms/send \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "to": "+1234567890", + "message": "Test message from SMS Gateway API" + }' +``` + +### Check System Stats + +```bash +curl http://localhost:3000/api/v1/admin/stats \ + -H "Authorization: Bearer YOUR_API_KEY" +``` + +## Next Steps + +1. **Read the full API documentation** in `README.md` +2. **Set up an Android gateway device** using the gateway API key from seeds +3. **Configure webhooks** for real-time SMS notifications +4. **Integrate OTP functionality** into your application + +## Common Issues + +### Redis Connection Error + +Make sure Redis is running: +```bash +redis-cli ping +``` +Should return `PONG`. + +### Database Connection Error + +Check your PostgreSQL is running and accessible: +```bash +psql -d my_smsa_pio_development +``` + +### Sidekiq Not Processing Jobs + +Ensure Sidekiq is running: +```bash +bundle exec sidekiq +``` + +## Development Workflow + +1. **Console**: `bin/rails console` +2. **Routes**: `bin/rails routes | grep api` +3. **Logs**: `tail -f log/development.log` +4. **Sidekiq Web UI**: Add to routes and visit `/sidekiq` + +## Getting Help + +- Check `README.md` for complete API documentation +- Check `CLAUDE.md` for development guidance +- Review logs in `log/development.log` and `log/sidekiq.log` + +--- + +Happy coding! 🚀 diff --git a/README.md b/README.md new file mode 100644 index 0000000..2058388 --- /dev/null +++ b/README.md @@ -0,0 +1,730 @@ +# SMS Gateway API + +A Ruby on Rails REST API and WebSocket server for managing SMS messaging through Android gateway devices. This system allows you to send and receive SMS messages programmatically, manage OTP codes, and integrate SMS capabilities into your applications. + +## Features + +- **Gateway Management**: Register and manage multiple Android SMS gateway devices +- **Inbound SMS**: Receive and process incoming SMS messages +- **Outbound SMS**: Send SMS messages through connected gateway devices +- **OTP Management**: Generate, send, and verify one-time passwords +- **WebSocket Communication**: Real-time bidirectional communication with gateway devices +- **Webhook Support**: Trigger webhooks for SMS events +- **Rate Limiting**: Protect API endpoints from abuse +- **Load Balancing**: Automatically distribute messages across multiple gateways +- **Auto Failover**: Retry failed messages and handle gateway offline scenarios + +## Tech Stack + +- **Ruby**: 3.4.7 +- **Rails**: 8.0.3 +- **Database**: PostgreSQL 14+ +- **Cache/Queue**: Redis 7+ +- **Background Jobs**: Sidekiq 7 +- **WebSocket**: Action Cable (Redis adapter) +- **API Authentication**: JWT + API Keys + +## Prerequisites + +- Ruby 3.4.7 +- PostgreSQL 14+ +- Redis 7+ +- Bundler 2.x + +## Installation + +### 1. Clone the repository + +```bash +git clone +cd MySMSAPio +``` + +### 2. Install dependencies + +```bash +bundle install +``` + +### 3. Set up environment variables + +Create a `.env` file in the root directory: + +```env +# Database +DATABASE_URL=postgresql://localhost/my_smsa_pio_development + +# Redis +REDIS_URL=redis://localhost:6379/1 + +# CORS +ALLOWED_ORIGINS=* + +# Phone validation +DEFAULT_COUNTRY_CODE=US + +# Rails +SECRET_KEY_BASE=your_secret_key_here +RAILS_ENV=development +``` + +### 4. Create and set up the database + +```bash +bin/rails db:create +bin/rails db:migrate +bin/rails db:seed +``` + +**Important**: Save the API keys displayed after seeding! They won't be shown again. + +### 5. Start Redis (if not running) + +```bash +redis-server +``` + +### 6. Start Sidekiq (background jobs) + +```bash +bundle exec sidekiq +``` + +### 7. Start the Rails server + +```bash +bin/rails server +``` + +The API will be available at `http://localhost:3000` + +## API Documentation + +### Base URL + +``` +Development: http://localhost:3000 +Production: https://your-domain.com +``` + +### Authentication + +All API endpoints (except gateway registration) require an API key in the Authorization header: + +``` +Authorization: Bearer your_api_key_here +``` + +There are two types of API keys: +- **Gateway Keys**: Start with `gw_live_` - used by Android gateway devices +- **Client Keys**: Start with `api_live_` - used by your applications + +--- + +## Gateway Device APIs + +### Register a New Gateway + +Register an Android device as an SMS gateway. + +**Endpoint**: `POST /api/v1/gateway/register` + +**Request**: +```json +{ + "device_id": "unique-device-identifier", + "name": "My Gateway Phone" +} +``` + +**Response** (201 Created): +```json +{ + "success": true, + "api_key": "gw_live_abc123...", + "device_id": "unique-device-identifier", + "websocket_url": "ws://localhost:3000/cable" +} +``` + +⚠️ **Important**: Save the `api_key` immediately. It will only be shown once! + +### Send Heartbeat + +Keep the gateway connection alive and update status. + +**Endpoint**: `POST /api/v1/gateway/heartbeat` + +**Headers**: `Authorization: Bearer gw_live_...` + +**Request**: +```json +{ + "status": "online", + "messages_in_queue": 5, + "battery_level": 85, + "signal_strength": 4 +} +``` + +**Response** (200 OK): +```json +{ + "success": true, + "pending_messages": 2 +} +``` + +### Report Received SMS + +Submit an SMS message received by the gateway device. + +**Endpoint**: `POST /api/v1/gateway/sms/received` + +**Headers**: `Authorization: Bearer gw_live_...` + +**Request**: +```json +{ + "sender": "+1234567890", + "message": "Hello, this is a test message", + "timestamp": "2025-10-19T10:30:00Z" +} +``` + +**Response** (200 OK): +```json +{ + "success": true, + "message_id": "msg_abc123..." +} +``` + +### Report SMS Delivery Status + +Update the delivery status of an outbound SMS. + +**Endpoint**: `POST /api/v1/gateway/sms/status` + +**Headers**: `Authorization: Bearer gw_live_...` + +**Request**: +```json +{ + "message_id": "msg_abc123", + "status": "delivered", + "error_message": null +} +``` + +Status values: `delivered`, `failed`, `sent` + +**Response** (200 OK): +```json +{ + "success": true +} +``` + +--- + +## Client Application APIs + +### Send SMS + +Send an SMS message through the gateway. + +**Endpoint**: `POST /api/v1/sms/send` + +**Headers**: `Authorization: Bearer api_live_...` + +**Request**: +```json +{ + "to": "+1234567890", + "message": "Your verification code is: 123456" +} +``` + +**Response** (202 Accepted): +```json +{ + "success": true, + "message_id": "msg_xyz789", + "status": "queued" +} +``` + +### Check SMS Status + +Check the delivery status of a sent message. + +**Endpoint**: `GET /api/v1/sms/status/:message_id` + +**Headers**: `Authorization: Bearer api_live_...` + +**Response** (200 OK): +```json +{ + "message_id": "msg_xyz789", + "status": "delivered", + "sent_at": "2025-10-19T10:30:00Z", + "delivered_at": "2025-10-19T10:30:05Z", + "failed_at": null, + "error_message": null +} +``` + +### Get Received SMS + +Retrieve inbound SMS messages. + +**Endpoint**: `GET /api/v1/sms/received` + +**Headers**: `Authorization: Bearer api_live_...` + +**Query Parameters**: +- `phone_number` (optional): Filter by phone number +- `since` (optional): ISO 8601 timestamp to filter messages after this time +- `limit` (optional): Number of messages per page (default: 50, max: 100) + +**Response** (200 OK): +```json +{ + "messages": [ + { + "message_id": "msg_abc", + "from": "+1234567890", + "message": "Reply message content", + "received_at": "2025-10-19T10:30:00Z" + } + ], + "total": 25, + "page": 1, + "pages": 1 +} +``` + +--- + +## OTP APIs + +### Send OTP + +Generate and send a one-time password. + +**Endpoint**: `POST /api/v1/otp/send` + +**Headers**: `Authorization: Bearer api_live_...` + +**Request**: +```json +{ + "phone_number": "+1234567890", + "purpose": "authentication", + "expiry_minutes": 5 +} +``` + +**Response** (200 OK): +```json +{ + "success": true, + "expires_at": "2025-10-19T10:35:00Z", + "message_id": "msg_otp123" +} +``` + +**Rate Limits**: Maximum 3 OTP codes per phone number per hour. + +### Verify OTP + +Verify an OTP code. + +**Endpoint**: `POST /api/v1/otp/verify` + +**Headers**: `Authorization: Bearer api_live_...` + +**Request**: +```json +{ + "phone_number": "+1234567890", + "code": "123456" +} +``` + +**Response** (200 OK) - Success: +```json +{ + "success": true, + "verified": true +} +``` + +**Response** (200 OK) - Failed: +```json +{ + "success": false, + "verified": false, + "error": "Invalid or expired OTP", + "attempts_remaining": 2 +} +``` + +--- + +## Admin APIs + +### List Gateways + +Get all registered gateway devices. + +**Endpoint**: `GET /api/v1/admin/gateways` + +**Headers**: `Authorization: Bearer api_live_...` + +**Response** (200 OK): +```json +{ + "gateways": [ + { + "id": 1, + "device_id": "test-gateway-001", + "name": "Test Gateway 1", + "status": "online", + "last_heartbeat_at": "2025-10-19T10:30:00Z", + "messages_sent_today": 145, + "messages_received_today": 23, + "total_messages_sent": 1543, + "total_messages_received": 892, + "active": true, + "priority": 1, + "metadata": {}, + "created_at": "2025-10-19T08:00:00Z" + } + ] +} +``` + +### Toggle Gateway Status + +Enable or disable a gateway. + +**Endpoint**: `POST /api/v1/admin/gateways/:id/toggle` + +**Headers**: `Authorization: Bearer api_live_...` + +**Response** (200 OK): +```json +{ + "success": true, + "gateway": { + "id": 1, + "device_id": "test-gateway-001", + "active": false + } +} +``` + +### Get System Statistics + +Get overall system statistics. + +**Endpoint**: `GET /api/v1/admin/stats` + +**Headers**: `Authorization: Bearer api_live_...` + +**Response** (200 OK): +```json +{ + "gateways": { + "total": 2, + "active": 2, + "online": 1, + "offline": 1 + }, + "messages": { + "total_sent": 5432, + "total_received": 892, + "sent_today": 168, + "received_today": 23, + "total_today": 191, + "pending": 3, + "failed_today": 2 + }, + "otp": { + "sent_today": 45, + "verified_today": 42, + "verification_rate": 93.33 + }, + "timestamp": "2025-10-19T10:30:00Z" +} +``` + +--- + +## WebSocket Connection (Gateway Devices) + +Gateway devices connect via WebSocket for real-time bidirectional communication. + +### Connection URL + +``` +ws://localhost:3000/cable?api_key=gw_live_your_key_here +``` + +### Subscribe to Gateway Channel + +```javascript +{ + "command": "subscribe", + "identifier": "{\"channel\":\"GatewayChannel\",\"api_key_digest\":\"sha256_hash_of_api_key\"}" +} +``` + +### Messages from Server + +**Send SMS Command**: +```json +{ + "action": "send_sms", + "message_id": "msg_123", + "recipient": "+1234567890", + "message": "Your OTP is: 123456" +} +``` + +### Messages to Server + +**Heartbeat**: +```json +{ + "action": "heartbeat", + "battery_level": 85, + "signal_strength": 4, + "messages_in_queue": 0 +} +``` + +**Delivery Report**: +```json +{ + "action": "delivery_report", + "message_id": "msg_123", + "status": "delivered" +} +``` + +**Message Received**: +```json +{ + "action": "message_received", + "sender": "+1234567890", + "message": "Hello", + "timestamp": "2025-10-19T10:30:00Z" +} +``` + +--- + +## Background Jobs + +The system uses Sidekiq for background processing: + +### Scheduled Jobs + +- **CheckGatewayHealthJob**: Runs every minute to mark offline gateways +- **CleanupExpiredOtpsJob**: Runs every 15 minutes to delete expired OTP codes +- **ResetDailyCountersJob**: Runs daily at midnight to reset message counters + +### Processing Jobs + +- **SendSmsJob**: Handles outbound SMS delivery +- **ProcessInboundSmsJob**: Processes received SMS and triggers webhooks +- **RetryFailedSmsJob**: Retries failed messages with exponential backoff +- **TriggerWebhookJob**: Executes webhook HTTP calls + +--- + +## Webhooks + +Configure webhooks to receive real-time notifications for SMS events. + +### Webhook Events + +- `sms_received`: Triggered when an inbound SMS is received +- `sms_sent`: Triggered when an outbound SMS is sent +- `sms_failed`: Triggered when an SMS fails to send + +### Webhook Payload Example + +```json +{ + "event": "sms_received", + "message_id": "msg_xyz", + "from": "+1234567890", + "message": "Hello", + "received_at": "2025-10-19T10:30:00Z", + "gateway_id": "test-gateway-001" +} +``` + +### Webhook Signature + +If a `secret_key` is configured, webhooks include an HMAC-SHA256 signature in the `X-Webhook-Signature` header for verification. + +--- + +## Error Responses + +All errors follow this format: + +```json +{ + "error": "Error message here" +} +``` + +Common HTTP status codes: +- `400 Bad Request`: Invalid request parameters +- `401 Unauthorized`: Missing or invalid API key +- `403 Forbidden`: Insufficient permissions +- `404 Not Found`: Resource not found +- `422 Unprocessable Entity`: Validation errors +- `429 Too Many Requests`: Rate limit exceeded +- `500 Internal Server Error`: Server error + +--- + +## Development + +### Running Tests + +```bash +bin/rails test +``` + +### Code Quality + +Check code style: +```bash +bin/rubocop +``` + +Security scan: +```bash +bin/brakeman +``` + +### Console Access + +```bash +bin/rails console +``` + +### Database Console + +```bash +bin/rails dbconsole +``` + +--- + +## Deployment + +### Using Kamal + +This project is configured for deployment with Kamal. + +```bash +# Deploy to production +bin/kamal deploy + +# View logs +bin/kamal logs + +# Access console +bin/kamal console +``` + +### Environment Variables (Production) + +Required environment variables: + +```env +DATABASE_URL=postgresql://user:pass@host/database +REDIS_URL=redis://host:6379/1 +SECRET_KEY_BASE=your_production_secret +RAILS_ENV=production +ALLOWED_ORIGINS=https://yourdomain.com +DEFAULT_COUNTRY_CODE=US +``` + +--- + +## Monitoring + +### Health Check + +``` +GET /up +``` + +Returns 200 if the application is healthy. + +### Sidekiq Web UI + +Mount Sidekiq web interface in `config/routes.rb` (protect with authentication in production): + +```ruby +require 'sidekiq/web' +mount Sidekiq::Web => '/sidekiq' +``` + +--- + +## Architecture + +``` +┌─────────────────┐ ┌──────────────────┐ +│ │ │ │ +│ Android SMS │◄───WS──►│ Rails API │ +│ Gateway App │ │ Action Cable │ +│ │ │ │ +└─────────────────┘ └────────┬─────────┘ + │ + ┌────────┴─────────┐ + │ │ + ┌───────────────────┤ PostgreSQL │ + │ │ (Messages, OTP) │ + │ │ │ + │ └──────────────────┘ + │ + │ ┌──────────────────┐ + │ │ │ + └──────────────────►│ Redis │ + │ (Cache, Jobs, │ + │ WebSockets) │ + │ │ + └────────┬─────────┘ + │ + ┌────────┴─────────┐ + │ │ + │ Sidekiq │ + │ (Background │ + │ Jobs) │ + │ │ + └──────────────────┘ +``` + +--- + +## License + +MIT License + +--- + +## Support + +For issues and questions, please create an issue on GitHub. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..9a5ea73 --- /dev/null +++ b/Rakefile @@ -0,0 +1,6 @@ +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require_relative "config/application" + +Rails.application.load_tasks diff --git a/SESSION_MIDDLEWARE_FIX.md b/SESSION_MIDDLEWARE_FIX.md new file mode 100644 index 0000000..784f978 --- /dev/null +++ b/SESSION_MIDDLEWARE_FIX.md @@ -0,0 +1,179 @@ +# Session & Flash Middleware Fix + +## Issue +``` +undefined method 'flash' for an instance of ActionDispatch::Request +``` + +This error occurred because the application was configured as **API-only mode** (`config.api_only = true`), which disables session and flash middleware by default. However, the admin interface needs these features for: +- Session-based authentication +- Flash messages (success/error notifications) +- CSRF protection + +## Solution +Manually include the required middleware in the application configuration while keeping API-only mode for the API endpoints. + +## Changes Made + +### 1. Updated Application Configuration +**File:** `config/application.rb` + +Added middleware explicitly: +```ruby +# Configure API-only mode (but keep session middleware for admin interface) +config.api_only = true + +# Include session and flash middleware for admin interface +# Even though this is an API-only app, we need sessions for the admin UI +config.middleware.use ActionDispatch::Cookies +config.middleware.use ActionDispatch::Session::CookieStore +config.middleware.use ActionDispatch::Flash +``` + +### 2. Updated Admin Base Controller +**File:** `app/controllers/admin/base_controller.rb` + +Added CSRF protection: +```ruby +module Admin + class BaseController < ActionController::Base + include Pagy::Backend + + # Enable session and flash for admin controllers + # (needed because the app is in API-only mode) + protect_from_forgery with: :exception + + layout "admin" + before_action :require_admin + # ... + end +end +``` + +## Why This Works + +### API-Only Mode Benefits (Kept) +- ✅ Faster API responses (no session overhead for API endpoints) +- ✅ RESTful API design +- ✅ No unnecessary middleware for API calls +- ✅ Better performance for mobile/external clients + +### Admin Interface Benefits (Added) +- ✅ Session-based authentication +- ✅ Flash messages for user feedback +- ✅ CSRF protection +- ✅ Cookie support for "remember me" features +- ✅ Standard Rails web app behavior + +### How Both Coexist +1. **API Controllers** (`ApplicationController < ActionController::API`) + - Don't use sessions or flash + - Use token-based authentication + - Remain lightweight and fast + +2. **Admin Controllers** (`Admin::BaseController < ActionController::Base`) + - Use sessions and flash + - Use cookie-based authentication + - Full Rails web app features + +## Architecture + +``` +Rails Application (API-only mode) +├── API Controllers (ActionController::API) +│ ├── /api/v1/sms +│ ├── /api/v1/otp +│ └── /api/v1/gateway/* +│ └── Uses: Token auth, JSON responses +│ +└── Admin Controllers (ActionController::Base) + ├── /admin/login + ├── /admin/dashboard + ├── /admin/api_keys + ├── /admin/logs + └── /admin/gateways + └── Uses: Session auth, HTML responses, flash messages +``` + +## Middleware Stack + +Now includes (in order): +1. `ActionDispatch::Cookies` - Cookie handling +2. `ActionDispatch::Session::CookieStore` - Session storage +3. `ActionDispatch::Flash` - Flash messages + +These are available to **all** controllers, but only admin controllers use them. + +## Verification + +Check middleware is loaded: +```bash +bin/rails middleware | grep -E "(Session|Flash|Cookies)" +``` + +Should output: +``` +use ActionDispatch::Cookies +use ActionDispatch::Session::CookieStore +use ActionDispatch::Flash +``` + +Test application: +```bash +bin/rails runner "puts 'AdminUser: ' + AdminUser.first.email" +``` + +## Security Considerations + +### CSRF Protection +- ✅ Enabled for admin controllers via `protect_from_forgery with: :exception` +- ✅ Automatically includes CSRF token in forms +- ✅ Validates token on POST/PUT/PATCH/DELETE requests + +### Session Security +- ✅ Uses encrypted cookie store +- ✅ Session expires when browser closes (default) +- ✅ Session data is signed and verified + +### Cookie Settings +Default configuration uses secure cookies in production: +- HttpOnly: Yes (prevents XSS) +- Secure: Yes in production (HTTPS only) +- SameSite: Lax (prevents CSRF) + +## No Impact on API + +The API endpoints remain unchanged: +- ✅ No session overhead +- ✅ No CSRF checks +- ✅ Token-based authentication still works +- ✅ Same performance characteristics + +## Testing + +### Test Admin Login +```bash +# Start server +bin/rails server + +# Visit in browser +open http://localhost:3000/admin/login +``` + +### Test API Endpoint +```bash +# Should work without sessions +curl -H "Authorization: Bearer your_api_key" \ + http://localhost:3000/api/v1/sms/received +``` + +## Summary + +✅ **Fixed**: Flash messages work in admin interface +✅ **Fixed**: Sessions work for authentication +✅ **Kept**: API-only mode for API endpoints +✅ **Kept**: Performance benefits of API-only +✅ **Added**: CSRF protection for admin +✅ **Added**: Cookie support for admin + +Both the API and admin interface now work correctly side-by-side! 🎉 diff --git a/STARTUP_GUIDE.md b/STARTUP_GUIDE.md new file mode 100644 index 0000000..b52604d --- /dev/null +++ b/STARTUP_GUIDE.md @@ -0,0 +1,281 @@ +# MySMSAPio - Startup Guide + +## ⚠️ Important: Configuration Changes Made + +The application configuration has been updated to support both API endpoints and the admin web interface. **You MUST restart your Rails server** for these changes to take effect. + +## Quick Start + +### 1. Stop Any Running Servers +If you have a Rails server running, stop it first (Ctrl+C). + +### 2. Start the Server + +**Option A: With Tailwind CSS watch (Recommended)** +```bash +bin/dev +``` +This starts both Rails server and Tailwind CSS watcher. + +**Option B: Rails server only** +```bash +bin/rails server +``` + +### 3. Access the Admin Interface + +Open your browser to: +``` +http://localhost:3000/admin/login +``` + +### 4. Login + +``` +Email: admin@example.com +Password: password123 +``` + +⚠️ **Remember to change this password in production!** + +## What Changed + +### Configuration Updates + +1. **Disabled API-Only Mode** + - File: `config/application.rb` + - Changed: Commented out `config.api_only = true` + - Reason: Allows admin interface to use sessions and flash messages + +2. **Added Session Store** + - File: `config/initializers/session_store.rb` + - Added: Cookie-based session configuration + - Reason: Required for admin authentication + +3. **CSRF Protection** + - All admin controllers have CSRF protection enabled + - Forms automatically include CSRF tokens + - API endpoints (ActionController::API) are unaffected + +### Why These Changes Were Needed + +**The Problem:** +- The app was configured as `api_only = true` +- This disables sessions, flash messages, and cookies +- Admin interface needs these features for authentication + +**The Solution:** +- Keep API controllers using `ActionController::API` (fast, stateless) +- Admin controllers use `ActionController::Base` (full Rails features) +- Both work together in the same app + +## Architecture + +``` +MySMSAPio Application +│ +├── API Endpoints (Fast & Stateless) +│ ├── /api/v1/sms/* +│ ├── /api/v1/otp/* +│ └── /api/v1/gateway/* +│ └── Controllers inherit from ActionController::API +│ └── Use: Token authentication, no sessions +│ +└── Admin Interface (Full Rails Features) + ├── /admin/login + ├── /admin/dashboard + ├── /admin/api_keys + ├── /admin/logs + └── /admin/gateways + └── Controllers inherit from ActionController::Base + └── Use: Session authentication, flash messages, cookies +``` + +## Troubleshooting + +### Error: "undefined method 'flash'" +**Solution:** Restart the Rails server. Configuration changes require a server restart. + +```bash +# Stop the server (Ctrl+C), then: +bin/dev +# or +bin/rails server +``` + +### Error: "Admin is not a module" +**Solution:** This was already fixed by renaming the model to `AdminUser`. If you see this: +```bash +bin/rails db:migrate +bin/rails db:seed +``` + +### Login Page Shows 404 +**Check routes:** +```bash +bin/rails routes | grep admin +``` + +Should show routes like `/admin/login`, `/admin/dashboard`, etc. + +### Session Not Persisting +**Check middleware:** +```bash +bin/rails middleware | grep Session +``` + +Should show: `use ActionDispatch::Session::CookieStore` + +### API Endpoints Not Working +The API endpoints should work exactly as before. Test with: +```bash +curl -H "Authorization: Bearer your_api_key" \ + http://localhost:3000/api/v1/sms/received +``` + +## Development Workflow + +### Daily Development +```bash +# Start everything +bin/dev + +# This runs: +# - Rails server (port 3000) +# - Tailwind CSS watcher (auto-rebuild on changes) +``` + +### Running Tests +```bash +bin/rails test +``` + +### Database Operations +```bash +# Reset database +bin/rails db:reset + +# Run migrations +bin/rails db:migrate + +# Seed data +bin/rails db:seed +``` + +### Code Quality +```bash +# Run RuboCop +bin/rubocop + +# Run Brakeman security scanner +bin/brakeman +``` + +## Production Deployment + +### Environment Variables +Make sure these are set: +```bash +DATABASE_URL=postgresql://... +REDIS_URL=redis://... +SECRET_KEY_BASE=your_secret_key +RAILS_ENV=production +``` + +### Precompile Assets +```bash +bin/rails assets:precompile +bin/rails tailwindcss:build +``` + +### Change Default Password +```bash +bin/rails console + +# In console: +admin = AdminUser.find_by(email: 'admin@example.com') +admin.update!(password: 'your_secure_password') +``` + +### Enable HTTPS +Update `config/environments/production.rb`: +```ruby +config.force_ssl = true +``` + +## Features Working + +✅ **Admin Dashboard** +- Real-time statistics +- Recent messages +- Gateway status + +✅ **API Keys Management** +- Create with permissions +- View all keys +- Revoke keys + +✅ **SMS Logs** +- Filter by direction, status, date +- View error messages +- Pagination + +✅ **Gateway Management** +- View all gateways +- Activate/deactivate +- Monitor health + +✅ **Authentication** +- Session-based login +- Flash messages +- CSRF protection + +✅ **Professional UI** +- Tailwind CSS theme +- Responsive design +- Animated indicators + +## Need Help? + +### Documentation +- `README.md` - Project overview +- `ADMIN_INTERFACE.md` - Complete admin documentation +- `ADMIN_QUICKSTART.md` - Quick reference +- `NAMESPACE_FIX.md` - Model naming fix +- `SESSION_MIDDLEWARE_FIX.md` - Middleware configuration + +### Logs +```bash +# Development log +tail -f log/development.log + +# Test log +tail -f log/test.log +``` + +### Rails Console +```bash +bin/rails console + +# Check AdminUser +AdminUser.count +AdminUser.first.email + +# Check API Keys +ApiKey.count + +# Check Gateways +Gateway.count +``` + +## Summary + +1. ✅ Configuration updated to support admin interface +2. ✅ Sessions and flash messages enabled +3. ✅ API endpoints remain fast and stateless +4. ✅ Both admin and API work together +5. ⚠️ **Must restart server after configuration changes** + +Start the server with `bin/dev` and access the admin at `http://localhost:3000/admin/login`! + +Happy coding! 🚀 diff --git a/WEBSOCKET_FIX.md b/WEBSOCKET_FIX.md new file mode 100644 index 0000000..c9822a9 --- /dev/null +++ b/WEBSOCKET_FIX.md @@ -0,0 +1,237 @@ +# WebSocket Subscription Rejection Fix + +## Issue +**Error**: "GatewayChannel is transmitting the subscription rejection" + +## Root Cause + +The `GatewayChannel#subscribed` method was attempting to re-authenticate the gateway using `params[:api_key_digest]`, but this caused two problems: + +1. **Double Authentication**: The gateway was already authenticated at the connection level in `ApplicationCable::Connection#connect` +2. **Missing Parameter**: The channel was expecting `api_key_digest` as a channel parameter, but it was never being passed +3. **Wrong Layer**: Authentication should happen at the connection level (before subscription), not at the channel subscription level + +## Authentication Flow + +### Correct Flow (After Fix) + +``` +1. Client connects to WebSocket + URL: ws://host/cable?api_key=gw_live_... + +2. ApplicationCable::Connection#connect + - Extracts api_key from query params + - Hashes it with SHA256 + - Finds gateway by api_key_digest + - Sets current_gateway + - Connection accepted ✅ + +3. Client subscribes to GatewayChannel + Message: {"command":"subscribe","identifier":"{\"channel\":\"GatewayChannel\"}"} + +4. GatewayChannel#subscribed + - Uses current_gateway (already authenticated) + - Streams to gateway-specific channel + - Updates heartbeat + - Subscription accepted ✅ +``` + +### Previous Flow (Before Fix) + +``` +1. Client connects to WebSocket + URL: ws://host/cable?api_key=gw_live_... + +2. ApplicationCable::Connection#connect + - Authenticates gateway + - Connection accepted ✅ + +3. Client subscribes to GatewayChannel + Message: {"command":"subscribe","identifier":"{\"channel\":\"GatewayChannel\"}"} + +4. GatewayChannel#subscribed (BROKEN) + - Looks for params[:api_key_digest] ❌ (doesn't exist) + - Gateway lookup fails ❌ + - Subscription rejected ❌ +``` + +## The Fix + +### Before (Broken) + +```ruby +class GatewayChannel < ApplicationCable::Channel + def subscribed + # Authenticate gateway using API key from params + api_key_digest = params[:api_key_digest] # ❌ This was never passed! + + unless api_key_digest + reject # ❌ Always rejected here + return + end + + @gateway = Gateway.find_by(api_key_digest: api_key_digest, active: true) + + unless @gateway + reject + return + end + + stream_for @gateway + @gateway.heartbeat! + Rails.logger.info("Gateway #{@gateway.device_id} connected via WebSocket") + end +end +``` + +### After (Fixed) + +```ruby +class GatewayChannel < ApplicationCable::Channel + def subscribed + # Gateway is already authenticated at the connection level + # current_gateway is set by ApplicationCable::Connection + @gateway = current_gateway # ✅ Use already-authenticated gateway + + unless @gateway + reject + return + end + + # Subscribe to gateway-specific channel + stream_for @gateway + + # Update gateway status + @gateway.heartbeat! + + Rails.logger.info("Gateway #{@gateway.device_id} subscribed to GatewayChannel") + end +end +``` + +## Key Changes + +1. **Removed** `params[:api_key_digest]` lookup (it was never passed) +2. **Use** `current_gateway` which is set by the connection authentication +3. **Simplified** authentication - it only happens once at the connection level +4. **Removed** redundant authentication check + +## Connection Authentication + +The connection-level authentication in `ApplicationCable::Connection` is the correct place for this: + +```ruby +module ApplicationCable + class Connection < ActionCable::Connection::Base + identified_by :current_gateway + + def connect + self.current_gateway = find_verified_gateway + logger.info "Gateway #{current_gateway.device_id} connected" + end + + private + + def find_verified_gateway + # Get API key from request params (query string) + api_key = request.params[:api_key] + + if api_key.blank? + reject_unauthorized_connection + end + + # Hash the API key and find gateway + api_key_digest = Digest::SHA256.hexdigest(api_key) + gateway = Gateway.find_by(api_key_digest: api_key_digest, active: true) + + if gateway + gateway + else + reject_unauthorized_connection + end + end + end +end +``` + +## Testing the Fix + +### Test with wscat + +```bash +wscat -c "ws://localhost:3000/cable?api_key=gw_live_your_key_here" +``` + +**After connection**, subscribe to the channel: + +```json +{"command":"subscribe","identifier":"{\"channel\":\"GatewayChannel\"}"} +``` + +**Expected Response**: + +```json +{ + "identifier": "{\"channel\":\"GatewayChannel\"}", + "type": "confirm_subscription" +} +``` + +### Test from Android + +```kotlin +val wsUrl = "ws://192.168.1.100:3000/cable?api_key=$apiKey" +val request = Request.Builder().url(wsUrl).build() + +webSocket = client.newWebSocket(request, object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + // Connection successful + + // Subscribe to channel + val subscribeMsg = JSONObject().apply { + put("command", "subscribe") + put("identifier", """{"channel":"GatewayChannel"}""") + } + webSocket.send(subscribeMsg.toString()) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + val json = JSONObject(text) + if (json.optString("type") == "confirm_subscription") { + // Subscription successful! ✅ + Log.d(TAG, "Subscribed to GatewayChannel") + } + } +}) +``` + +## Why This Matters + +### Before Fix +- ❌ All subscription attempts were rejected +- ❌ Gateways couldn't receive SMS send commands +- ❌ Real-time communication was broken +- ❌ Messages stuck in "pending" state + +### After Fix +- ✅ Subscriptions work correctly +- ✅ Gateways receive SMS send commands in real-time +- ✅ Bidirectional communication enabled +- ✅ Messages sent immediately to online gateways + +## Related Files + +- `app/channels/application_cable/connection.rb` - Connection authentication (unchanged, already correct) +- `app/channels/gateway_channel.rb` - Channel subscription (FIXED) +- `CABLE_DOCUMENTATION.md` - WebSocket integration guide (already correct) +- `API_DOCUMENTATION.md` - Full API docs (already correct) + +## Summary + +The fix removes redundant authentication from the channel subscription layer and properly uses the `current_gateway` that was already authenticated at the connection level. This follows Action Cable best practices where: + +1. **Connection authentication** happens when WebSocket connects +2. **Channel subscription** happens after connection is authenticated +3. **No re-authentication** needed in channels - use `current_gateway` or other identifiers set at connection level + +WebSocket connections from Android devices should now work correctly! 🚀 diff --git a/WEBSOCKET_SETUP.md b/WEBSOCKET_SETUP.md new file mode 100644 index 0000000..da2445d --- /dev/null +++ b/WEBSOCKET_SETUP.md @@ -0,0 +1,502 @@ +# WebSocket Connection Setup + +## Issue Fixed + +**Problem**: Android gateway device couldn't connect to WebSocket server + +**Error**: +``` +Request origin not allowed +Failed to upgrade to WebSocket (REQUEST_METHOD: GET, HTTP_CONNECTION: Upgrade, HTTP_UPGRADE: websocket) +``` + +**Root Cause**: Action Cable was blocking WebSocket connections from devices on the local network (192.168.x.x) due to origin restrictions. + +## Solution Applied + +### Configuration Changes + +**File**: `config/environments/development.rb` + +Added the following configuration: + +```ruby +# Allow Action Cable connections from any origin in development +config.action_cable.disable_request_forgery_protection = true + +# Allow WebSocket connections from local network +config.action_cable.allowed_request_origins = [ + /http:\/\/localhost.*/, + /http:\/\/127\.0\.0\.1.*/, + /http:\/\/192\.168\..*/, # Local network (192.168.0.0/16) + /http:\/\/10\..*/, # Local network (10.0.0.0/8) + /http:\/\/172\.(1[6-9]|2[0-9]|3[0-1])\..*/ # Local network (172.16.0.0/12) +] +``` + +### What This Does + +1. **Disables CSRF protection** for Action Cable in development + - Allows WebSocket connections without origin validation + - Safe for development environment + - **Should NOT be used in production** + +2. **Allows specific origins**: + - `localhost` and `127.0.0.1` (local machine) + - `192.168.x.x` (most common home/office networks) + - `10.x.x.x` (enterprise networks, Docker) + - `172.16-31.x.x` (Docker bridge networks) + +## How to Apply + +1. **Restart Rails server**: +```bash +# Stop current server (Ctrl+C or kill process) +lsof -ti:3000 | xargs kill -9 + +# Start server again +bin/dev +``` + +2. **Verify Redis is running**: +```bash +redis-cli ping +# Should return: PONG +``` + +If Redis is not running: +```bash +# macOS (with Homebrew) +brew services start redis + +# Linux +sudo systemctl start redis + +# Or run manually +redis-server +``` + +## Testing WebSocket Connection + +### Using wscat (Command Line) + +Install wscat: +```bash +npm install -g wscat +``` + +Test connection: +```bash +# Replace with your actual API key +wscat -c "ws://localhost:3000/cable?api_key=gw_live_your_key_here" +``` + +Expected output: +```json +{"type":"welcome"} +``` + +### Using Android App + +1. **Get API key** from admin interface (when creating gateway) +2. **Configure in Android app**: + - API Base URL: `http://192.168.x.x:3000` (your computer's IP) + - WebSocket URL: `ws://192.168.x.x:3000/cable` + - API Key: `gw_live_...` +3. **Connect** in the app +4. **Check Rails logs** for connection message: +``` +Gateway device-001 connected +``` + +### Finding Your Computer's IP Address + +**macOS**: +```bash +ipconfig getifaddr en0 # WiFi +ipconfig getifaddr en1 # Ethernet +``` + +**Linux**: +```bash +hostname -I | awk '{print $1}' +``` + +**Windows**: +```cmd +ipconfig | findstr IPv4 +``` + +## Connection Flow + +### 1. Client Connects + +Android app initiates WebSocket connection: +``` +ws://192.168.x.x:3000/cable?api_key=gw_live_abc123... +``` + +### 2. Server Authenticates + +**File**: `app/channels/application_cable/connection.rb` + +```ruby +def connect + self.current_gateway = find_verified_gateway + logger.info "Gateway #{current_gateway.device_id} connected" +end + +private + +def find_verified_gateway + api_key = request.params[:api_key] + return reject_unauthorized_connection if api_key.blank? + + api_key_digest = Digest::SHA256.hexdigest(api_key) + gateway = Gateway.find_by(api_key_digest: api_key_digest, active: true) + + gateway || reject_unauthorized_connection +end +``` + +### 3. Welcome Message + +Server sends welcome: +```json +{"type":"welcome"} +``` + +### 4. Gateway Subscribes + +Android app subscribes to GatewayChannel: +```json +{ + "command": "subscribe", + "identifier": "{\"channel\":\"GatewayChannel\"}" +} +``` + +### 5. Server Confirms + +```json +{ + "identifier": "{\"channel\":\"GatewayChannel\"}", + "type": "confirm_subscription" +} +``` + +### 6. Communication Begins + +Gateway can now: +- Send heartbeats +- Report received SMS +- Report delivery status +- Receive SMS to send + +## Production Configuration + +### Important: Different Settings for Production + +**File**: `config/environments/production.rb` + +**DO NOT** disable CSRF protection in production. Instead, specify exact allowed origins: + +```ruby +# Production settings (recommended) +config.action_cable.allowed_request_origins = [ + 'https://yourdomain.com', + 'https://www.yourdomain.com' +] + +# Or allow all HTTPS origins (less secure) +config.action_cable.allowed_request_origins = /https:.*/ + +# NEVER do this in production: +# config.action_cable.disable_request_forgery_protection = true +``` + +### Production WebSocket URL + +Use `wss://` (WebSocket Secure) instead of `ws://`: + +``` +wss://api.yourdomain.com/cable +``` + +### SSL/TLS Requirements + +1. **Enable SSL** in Rails: +```ruby +# config/environments/production.rb +config.force_ssl = true +``` + +2. **Configure Puma** for SSL: +```ruby +# config/puma.rb +ssl_bind '0.0.0.0', '3000', { + key: '/path/to/server.key', + cert: '/path/to/server.crt' +} +``` + +3. **Or use reverse proxy** (recommended): + - Nginx or Apache with SSL + - Cloudflare + - AWS Application Load Balancer + - Heroku (automatic SSL) + +## Troubleshooting + +### Connection Refused + +**Error**: `Failed to upgrade to WebSocket` + +**Causes**: +1. Rails server not running +2. Redis not running +3. Wrong port +4. Firewall blocking + +**Solutions**: +```bash +# Check if server is running +lsof -i:3000 + +# Check if Redis is running +redis-cli ping + +# Check Rails logs +tail -f log/development.log + +# Restart server +bin/dev +``` + +### Authentication Failed + +**Error**: `Unauthorized` + +**Causes**: +1. Wrong API key +2. Gateway not active +3. API key format incorrect + +**Solutions**: +```bash +# Test API key in console +bin/rails console +``` + +```ruby +api_key = "gw_live_abc123..." +digest = Digest::SHA256.hexdigest(api_key) +gateway = Gateway.find_by(api_key_digest: digest) +puts gateway&.device_id || "NOT FOUND" +puts "Active: #{gateway&.active}" +``` + +### Origin Not Allowed (in production) + +**Error**: `Request origin not allowed` + +**Cause**: Origin not in `allowed_request_origins` + +**Solution**: Add your domain to allowed origins: +```ruby +config.action_cable.allowed_request_origins = [ + 'https://yourdomain.com', + 'https://api.yourdomain.com' +] +``` + +### Connection Drops Frequently + +**Causes**: +1. Network instability +2. Server restarting +3. Redis connection issues +4. Heartbeat timeout + +**Solutions**: +1. **Implement reconnection** in Android app +2. **Monitor Redis**: +```bash +redis-cli +> CLIENT LIST +> MONITOR +``` +3. **Check heartbeat interval**: Gateway should send heartbeat every < 2 minutes +4. **Review server logs** for errors + +## Monitoring WebSocket Connections + +### View Active Connections + +**In Rails console**: +```ruby +ActionCable.server.connections.size +``` + +### Monitor Redis + +```bash +redis-cli +> CLIENT LIST | grep cable +> SUBSCRIBE sms_gateway_development* +``` + +### Check Gateway Status + +```ruby +# In Rails console +Gateway.online.each do |g| + puts "#{g.name}: Last heartbeat #{g.last_heartbeat_at}" +end +``` + +### View Connection Logs + +```bash +# Development logs +tail -f log/development.log | grep -i "cable\|gateway\|websocket" + +# Production logs +tail -f log/production.log | grep -i "cable\|gateway\|websocket" +``` + +## Security Considerations + +### Development (Current Setup) + +✅ **Acceptable**: +- CSRF protection disabled +- All origins allowed +- HTTP connections +- Local network only + +⚠️ **Not for production** + +### Production Requirements + +🔒 **Must Have**: +- HTTPS/WSS only (`config.force_ssl = true`) +- Specific allowed origins +- CSRF protection enabled +- Rate limiting on connections +- Authentication required +- Connection monitoring +- Audit logging + +### Best Practices + +1. **API Key Security**: + - Never log API keys + - Use environment variables + - Rotate keys regularly + - Revoke compromised keys immediately + +2. **Connection Limits**: + - Limit connections per gateway + - Implement backoff strategy + - Monitor connection attempts + - Alert on suspicious activity + +3. **Network Security**: + - Use VPN for remote access + - Implement IP whitelisting + - Use SSL/TLS certificates + - Enable firewall rules + +## Example Android App Code + +### Connecting to WebSocket + +```kotlin +import okhttp3.* +import java.util.concurrent.TimeUnit + +class GatewayWebSocket( + private val apiKey: String, + private val websocketUrl: String +) { + private var webSocket: WebSocket? = null + private val client = OkHttpClient.Builder() + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .pingInterval(30, TimeUnit.SECONDS) + .build() + + fun connect() { + val request = Request.Builder() + .url("$websocketUrl?api_key=$apiKey") + .build() + + webSocket = client.newWebSocket(request, object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + Log.d("WebSocket", "Connected!") + subscribe() + } + + override fun onMessage(webSocket: WebSocket, text: String) { + Log.d("WebSocket", "Received: $text") + handleMessage(text) + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + Log.e("WebSocket", "Connection failed: ${t.message}") + reconnect() + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + Log.d("WebSocket", "Closed: $reason") + reconnect() + } + }) + } + + private fun subscribe() { + val subscribeMessage = """ + { + "command": "subscribe", + "identifier": "{\"channel\":\"GatewayChannel\"}" + } + """.trimIndent() + webSocket?.send(subscribeMessage) + } + + private fun handleMessage(json: String) { + val message = JSONObject(json) + when (message.optString("type")) { + "welcome" -> Log.d("WebSocket", "Welcome received") + "ping" -> Log.d("WebSocket", "Ping received") + "confirm_subscription" -> Log.d("WebSocket", "Subscribed to GatewayChannel") + } + } + + fun disconnect() { + webSocket?.close(1000, "Normal closure") + } + + private fun reconnect() { + Handler(Looper.getMainLooper()).postDelayed({ + connect() + }, 5000) // Reconnect after 5 seconds + } +} +``` + +## Summary + +✅ **Fixed**: WebSocket connection now works from local network +✅ **Configuration**: Action Cable allows connections from 192.168.x.x, 10.x.x.x, etc. +✅ **Security**: CSRF protection disabled for development only +✅ **Production**: Different, more secure settings required + +**Next Steps**: +1. Restart your Rails server +2. Ensure Redis is running +3. Try connecting from Android app +4. Check Rails logs for "Gateway XXX connected" message + +The WebSocket server is now ready to accept connections from your Android gateway devices on the local network! 🚀 diff --git a/app/assets/builds/.keep b/app/assets/builds/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/assets/images/.keep b/app/assets/images/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css new file mode 100644 index 0000000..fe93333 --- /dev/null +++ b/app/assets/stylesheets/application.css @@ -0,0 +1,10 @@ +/* + * This is a manifest file that'll be compiled into application.css. + * + * With Propshaft, assets are served efficiently without preprocessing steps. You can still include + * application-wide styles in this file, but keep in mind that CSS precedence will follow the standard + * cascading order, meaning styles declared later in the document or manifest will override earlier ones, + * depending on specificity. + * + * Consider organizing styles into separate files for maintainability. + */ diff --git a/app/assets/tailwind/application.css b/app/assets/tailwind/application.css new file mode 100644 index 0000000..eed580c --- /dev/null +++ b/app/assets/tailwind/application.css @@ -0,0 +1,36 @@ +@import "tailwindcss"; + +/* Custom Admin Theme */ +@theme { + --color-primary-50: #eff6ff; + --color-primary-100: #dbeafe; + --color-primary-200: #bfdbfe; + --color-primary-300: #93c5fd; + --color-primary-400: #60a5fa; + --color-primary-500: #3b82f6; + --color-primary-600: #2563eb; + --color-primary-700: #1d4ed8; + --color-primary-800: #1e40af; + --color-primary-900: #1e3a8a; + + --font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #f1f5f9; +} + +::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #94a3b8; +} diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb new file mode 100644 index 0000000..d672697 --- /dev/null +++ b/app/channels/application_cable/channel.rb @@ -0,0 +1,4 @@ +module ApplicationCable + class Channel < ActionCable::Channel::Base + end +end diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb new file mode 100644 index 0000000..5125777 --- /dev/null +++ b/app/channels/application_cable/connection.rb @@ -0,0 +1,31 @@ +module ApplicationCable + class Connection < ActionCable::Connection::Base + identified_by :current_gateway + + def connect + self.current_gateway = find_verified_gateway + logger.info "Gateway #{current_gateway.device_id} connected" + end + + private + + def find_verified_gateway + # Get API key from request params + api_key = request.params[:api_key] + + if api_key.blank? + reject_unauthorized_connection + end + + # Hash the API key and find gateway + api_key_digest = Digest::SHA256.hexdigest(api_key) + gateway = Gateway.find_by(api_key_digest: api_key_digest, active: true) + + if gateway + gateway + else + reject_unauthorized_connection + end + end + end +end diff --git a/app/channels/gateway_channel.rb b/app/channels/gateway_channel.rb new file mode 100644 index 0000000..724ce1f --- /dev/null +++ b/app/channels/gateway_channel.rb @@ -0,0 +1,99 @@ +class GatewayChannel < ApplicationCable::Channel + def subscribed + # Gateway is already authenticated at the connection level + # current_gateway is set by ApplicationCable::Connection + @gateway = current_gateway + + unless @gateway + reject + return + end + + # Subscribe to gateway-specific channel + stream_for @gateway + + # Update gateway status + @gateway.heartbeat! + + Rails.logger.info("Gateway #{@gateway.device_id} subscribed to GatewayChannel") + end + + def unsubscribed + # Update gateway status when disconnected + if @gateway + @gateway.mark_offline! + Rails.logger.info("Gateway #{@gateway.device_id} disconnected from WebSocket") + end + end + + def receive(data) + return unless @gateway + + # Handle incoming messages from gateway + case data["action"] + when "heartbeat" + handle_heartbeat(data) + when "delivery_report" + handle_delivery_report(data) + when "message_received" + handle_message_received(data) + else + Rails.logger.warn("Unknown action received: #{data['action']}") + end + rescue StandardError => e + Rails.logger.error("Error processing gateway message: #{e.message}") + end + + private + + def handle_heartbeat(data) + @gateway.heartbeat! + + # Update metadata if provided + metadata = { + battery_level: data["battery_level"], + signal_strength: data["signal_strength"], + messages_in_queue: data["messages_in_queue"] + }.compact + + @gateway.update(metadata: metadata) if metadata.any? + end + + def handle_delivery_report(data) + message_id = data["message_id"] + status = data["status"] + error_message = data["error_message"] + + sms = SmsMessage.find_by(message_id: message_id) + return unless sms + + case status + when "delivered" + sms.mark_delivered! + when "failed" + sms.mark_failed!(error_message) + RetryFailedSmsJob.perform_later(sms.id) if sms.can_retry? + end + end + + def handle_message_received(data) + sender = data["sender"] + message = data["message"] + timestamp = data["timestamp"] || Time.current + + # Create inbound SMS + sms = SmsMessage.create!( + gateway: @gateway, + direction: "inbound", + phone_number: sender, + message_body: message, + status: "delivered", + delivered_at: timestamp + ) + + @gateway.increment_received_count! + + # Process inbound message + ProcessInboundSmsJob.perform_later(sms.id) + end +end diff --git a/app/controllers/admin/api_keys_controller.rb b/app/controllers/admin/api_keys_controller.rb new file mode 100644 index 0000000..85e851d --- /dev/null +++ b/app/controllers/admin/api_keys_controller.rb @@ -0,0 +1,76 @@ +module Admin + class ApiKeysController < BaseController + def index + @api_keys = ApiKey.order(created_at: :desc) + end + + def new + @api_key = ApiKey.new + end + + def create + # Build permissions hash + permissions = {} + permissions["send_sms"] = params.dig(:api_key, :send_sms) == "1" + permissions["receive_sms"] = params.dig(:api_key, :receive_sms) == "1" + permissions["manage_gateways"] = params.dig(:api_key, :manage_gateways) == "1" + permissions["manage_otp"] = params.dig(:api_key, :manage_otp) == "1" + + # Parse expiration date if provided + expires_at = if params.dig(:api_key, :expires_at).present? + Time.parse(params[:api_key][:expires_at]) + else + nil + end + + # Generate API key + result = ApiKey.generate!( + name: params[:api_key][:name], + permissions: permissions, + expires_at: expires_at + ) + + # Store in session to pass to show action + session[:new_api_key_id] = result[:api_key].id + session[:new_api_raw_key] = result[:raw_key] + + redirect_to admin_api_key_path(result[:api_key]) + rescue StandardError => e + Rails.logger.error "API Key creation failed: #{e.message}\n#{e.backtrace.join("\n")}" + flash.now[:alert] = "Error creating API key: #{e.message}" + @api_key = ApiKey.new(name: params.dig(:api_key, :name)) + render :new, status: :unprocessable_entity + end + + def show + @api_key = ApiKey.find(params[:id]) + + # Check if this is a newly created key (from session) + if session[:new_api_key_id] == @api_key.id && session[:new_api_raw_key].present? + @raw_key = session[:new_api_raw_key] + # Clear session data after retrieving + session.delete(:new_api_key_id) + session.delete(:new_api_raw_key) + else + # This is an existing key being viewed (shouldn't normally happen) + redirect_to admin_api_keys_path, alert: "Cannot view API key details after creation" + end + end + + def destroy + @api_key = ApiKey.find(params[:id]) + @api_key.revoke! + redirect_to admin_api_keys_path, notice: "API key revoked successfully" + rescue => e + redirect_to admin_api_keys_path, alert: "Error revoking API key: #{e.message}" + end + + def toggle + @api_key = ApiKey.find(params[:id]) + @api_key.update!(active: !@api_key.active) + redirect_to admin_api_keys_path, notice: "API key #{@api_key.active? ? 'activated' : 'deactivated'}" + rescue => e + redirect_to admin_api_keys_path, alert: "Error updating API key: #{e.message}" + end + end +end diff --git a/app/controllers/admin/api_tester_controller.rb b/app/controllers/admin/api_tester_controller.rb new file mode 100644 index 0000000..bce62f8 --- /dev/null +++ b/app/controllers/admin/api_tester_controller.rb @@ -0,0 +1,8 @@ +module Admin + class ApiTesterController < BaseController + def index + @api_keys = ApiKey.active_keys.order(created_at: :desc) + @gateways = Gateway.order(created_at: :desc) + end + end +end diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb new file mode 100644 index 0000000..ff876a6 --- /dev/null +++ b/app/controllers/admin/base_controller.rb @@ -0,0 +1,30 @@ +module Admin + class BaseController < ActionController::Base + include Pagy::Backend + + # Enable session and flash for admin controllers + # (needed because the app is in API-only mode) + protect_from_forgery with: :exception + + layout "admin" + before_action :require_admin + + private + + def current_admin + @current_admin ||= AdminUser.find_by(id: session[:admin_id]) if session[:admin_id] + end + helper_method :current_admin + + def logged_in? + current_admin.present? + end + helper_method :logged_in? + + def require_admin + unless logged_in? + redirect_to admin_login_path, alert: "Please log in to continue" + end + end + end +end diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb new file mode 100644 index 0000000..b5b3c94 --- /dev/null +++ b/app/controllers/admin/dashboard_controller.rb @@ -0,0 +1,20 @@ +module Admin + class DashboardController < BaseController + def index + @stats = { + total_gateways: Gateway.count, + online_gateways: Gateway.online.count, + total_api_keys: ApiKey.count, + active_api_keys: ApiKey.active_keys.count, + messages_today: SmsMessage.where("created_at >= ?", Time.current.beginning_of_day).count, + messages_sent_today: SmsMessage.where("created_at >= ? AND direction = ?", Time.current.beginning_of_day, "outbound").count, + messages_received_today: SmsMessage.where("created_at >= ? AND direction = ?", Time.current.beginning_of_day, "inbound").count, + failed_messages_today: SmsMessage.where("created_at >= ? AND status = ?", Time.current.beginning_of_day, "failed").count, + pending_messages: SmsMessage.pending.count + } + + @recent_messages = SmsMessage.order(created_at: :desc).limit(10) + @recent_gateways = Gateway.order(last_heartbeat_at: :desc).limit(5) + end + end +end diff --git a/app/controllers/admin/gateways_controller.rb b/app/controllers/admin/gateways_controller.rb new file mode 100644 index 0000000..d3e46d9 --- /dev/null +++ b/app/controllers/admin/gateways_controller.rb @@ -0,0 +1,152 @@ +module Admin + class GatewaysController < BaseController + def index + @gateways = Gateway.order(created_at: :desc) + end + + def new + @gateway = Gateway.new + end + + def create + @gateway = Gateway.new( + device_id: params[:gateway][:device_id], + name: params[:gateway][:name], + priority: params[:gateway][:priority] || 1, + status: "offline" + ) + + # Generate API key for the gateway + raw_key = @gateway.generate_api_key! + + # Store in session to pass to show action + session[:new_gateway_id] = @gateway.id + session[:new_gateway_raw_key] = raw_key + + redirect_to admin_gateway_path(@gateway) + rescue StandardError => e + Rails.logger.error "Gateway creation failed: #{e.message}\n#{e.backtrace.join("\n")}" + flash.now[:alert] = "Error creating gateway: #{e.message}" + @gateway ||= Gateway.new + render :new, status: :unprocessable_entity + end + + def show + @gateway = Gateway.find(params[:id]) + + # Check if this is a newly created gateway (from session) + if session[:new_gateway_id] == @gateway.id && session[:new_gateway_raw_key].present? + @raw_key = session[:new_gateway_raw_key] + @is_new = true + + # Generate QR code with configuration data + @qr_code_data = generate_qr_code_data(@raw_key) + + # Clear session data after retrieving + session.delete(:new_gateway_id) + session.delete(:new_gateway_raw_key) + else + @is_new = false + @recent_messages = SmsMessage.where(gateway_id: @gateway.id).order(created_at: :desc).limit(20) + end + end + + private + + def generate_qr_code_data(api_key) + require "rqrcode" + + # Determine the base URL and WebSocket URL + base_url = request.base_url + ws_url = request.base_url.sub(/^http/, "ws") + "/cable" + + # Create JSON configuration for the Android app + config_data = { + api_key: api_key, + api_base_url: base_url, + websocket_url: ws_url, + version: "1.0" + }.to_json + + # Generate QR code + qr = RQRCode::QRCode.new(config_data, level: :h) + + # Return as SVG string + qr.as_svg( + offset: 0, + color: "000", + shape_rendering: "crispEdges", + module_size: 4, + standalone: true, + use_path: true + ) + end + + def toggle + @gateway = Gateway.find(params[:id]) + @gateway.update!(active: !@gateway.active) + redirect_to admin_gateways_path, notice: "Gateway #{@gateway.active? ? 'activated' : 'deactivated'}" + rescue => e + redirect_to admin_gateways_path, alert: "Error updating gateway: #{e.message}" + end + + def test + @gateway = Gateway.find(params[:id]) + rescue ActiveRecord::RecordNotFound + redirect_to admin_gateways_path, alert: "Gateway not found" + end + + def check_connection + @gateway = Gateway.find(params[:id]) + + # Check if gateway is online based on recent heartbeat + if @gateway.online? + render json: { + status: "success", + message: "Gateway is online", + last_heartbeat: @gateway.last_heartbeat_at, + time_ago: helpers.time_ago_in_words(@gateway.last_heartbeat_at) + } + else + render json: { + status: "error", + message: "Gateway is offline", + last_heartbeat: @gateway.last_heartbeat_at, + time_ago: @gateway.last_heartbeat_at ? helpers.time_ago_in_words(@gateway.last_heartbeat_at) : "never" + } + end + rescue StandardError => e + render json: { status: "error", message: e.message }, status: :internal_server_error + end + + def send_test_sms + @gateway = Gateway.find(params[:id]) + phone_number = params[:phone_number] + message_body = params[:message_body] + + if phone_number.blank? || message_body.blank? + render json: { status: "error", message: "Phone number and message are required" }, status: :unprocessable_entity + return + end + + # Create test SMS message + sms = SmsMessage.create!( + direction: "outbound", + phone_number: phone_number, + message_body: message_body, + gateway: @gateway, + metadata: { test: true, sent_from: "admin_interface" } + ) + + render json: { + status: "success", + message: "Test SMS queued for sending", + message_id: sms.message_id, + sms_status: sms.status + } + rescue StandardError => e + Rails.logger.error "Test SMS failed: #{e.message}\n#{e.backtrace.join("\n")}" + render json: { status: "error", message: e.message }, status: :internal_server_error + end + end +end diff --git a/app/controllers/admin/logs_controller.rb b/app/controllers/admin/logs_controller.rb new file mode 100644 index 0000000..9ff6199 --- /dev/null +++ b/app/controllers/admin/logs_controller.rb @@ -0,0 +1,37 @@ +module Admin + class LogsController < BaseController + def index + @pagy, @messages = pagy( + apply_filters(SmsMessage).order(created_at: :desc), + items: 50 + ) + + respond_to do |format| + format.html + format.turbo_stream + end + end + + private + + def apply_filters(scope) + scope = scope.where(direction: params[:direction]) if params[:direction].present? + scope = scope.where(status: params[:status]) if params[:status].present? + scope = scope.where(gateway_id: params[:gateway_id]) if params[:gateway_id].present? + + if params[:phone_number].present? + scope = scope.where("phone_number LIKE ?", "%#{params[:phone_number]}%") + end + + if params[:start_date].present? + scope = scope.where("created_at >= ?", Time.parse(params[:start_date])) + end + + if params[:end_date].present? + scope = scope.where("created_at <= ?", Time.parse(params[:end_date]).end_of_day) + end + + scope + end + end +end diff --git a/app/controllers/admin/sessions_controller.rb b/app/controllers/admin/sessions_controller.rb new file mode 100644 index 0000000..0eafbbc --- /dev/null +++ b/app/controllers/admin/sessions_controller.rb @@ -0,0 +1,38 @@ +module Admin + class SessionsController < ActionController::Base + layout "admin" + + # CSRF protection is enabled by default in ActionController::Base + # We need it for the create action but not for the new (GET) action + protect_from_forgery with: :exception + + def new + redirect_to admin_dashboard_path if current_admin + end + + def create + admin = AdminUser.find_by(email: params[:email]&.downcase) + + if admin&.authenticate(params[:password]) + session[:admin_id] = admin.id + admin.update_last_login! + redirect_to admin_dashboard_path, notice: "Welcome back, #{admin.name}!" + else + flash.now[:alert] = "Invalid email or password" + render :new, status: :unprocessable_entity + end + end + + def destroy + session.delete(:admin_id) + redirect_to admin_login_path, notice: "You have been logged out" + end + + private + + def current_admin + @current_admin ||= AdminUser.find_by(id: session[:admin_id]) if session[:admin_id] + end + helper_method :current_admin + end +end diff --git a/app/controllers/api/v1/admin/gateways_controller.rb b/app/controllers/api/v1/admin/gateways_controller.rb new file mode 100644 index 0000000..888227f --- /dev/null +++ b/app/controllers/api/v1/admin/gateways_controller.rb @@ -0,0 +1,49 @@ +module Api + module V1 + module Admin + class GatewaysController < ApplicationController + include ApiAuthenticatable + + # GET /api/v1/admin/gateways + def index + gateways = ::Gateway.order(created_at: :desc) + + render json: { + gateways: gateways.map { |gateway| + { + id: gateway.id, + device_id: gateway.device_id, + name: gateway.name, + status: gateway.status, + last_heartbeat_at: gateway.last_heartbeat_at, + messages_sent_today: gateway.messages_sent_today, + messages_received_today: gateway.messages_received_today, + total_messages_sent: gateway.total_messages_sent, + total_messages_received: gateway.total_messages_received, + active: gateway.active, + priority: gateway.priority, + metadata: gateway.metadata, + created_at: gateway.created_at + } + } + } + end + + # POST /api/v1/admin/gateways/:id/toggle + def toggle + gateway = ::Gateway.find(params[:id]) + gateway.update!(active: !gateway.active) + + render json: { + success: true, + gateway: { + id: gateway.id, + device_id: gateway.device_id, + active: gateway.active + } + } + end + end + end + end +end diff --git a/app/controllers/api/v1/admin/stats_controller.rb b/app/controllers/api/v1/admin/stats_controller.rb new file mode 100644 index 0000000..fcb5ec4 --- /dev/null +++ b/app/controllers/api/v1/admin/stats_controller.rb @@ -0,0 +1,60 @@ +module Api + module V1 + module Admin + class StatsController < ApplicationController + include ApiAuthenticatable + + # GET /api/v1/admin/stats + def index + today = Time.current.beginning_of_day + + # Gateway stats + total_gateways = ::Gateway.count + active_gateways = ::Gateway.active.count + online_gateways = ::Gateway.online.count + + # Message stats + total_messages_sent = ::Gateway.sum(:total_messages_sent) + total_messages_received = ::Gateway.sum(:total_messages_received) + + messages_sent_today = ::Gateway.sum(:messages_sent_today) + messages_received_today = ::Gateway.sum(:messages_received_today) + + # Pending and failed messages + pending_messages = SmsMessage.pending.count + failed_messages_today = SmsMessage.failed + .where("created_at >= ?", today) + .count + + # OTP stats + otps_sent_today = OtpCode.where("created_at >= ?", today).count + otps_verified_today = OtpCode.where("verified_at >= ?", today).count + + render json: { + gateways: { + total: total_gateways, + active: active_gateways, + online: online_gateways, + offline: total_gateways - online_gateways + }, + messages: { + total_sent: total_messages_sent, + total_received: total_messages_received, + sent_today: messages_sent_today, + received_today: messages_received_today, + total_today: messages_sent_today + messages_received_today, + pending: pending_messages, + failed_today: failed_messages_today + }, + otp: { + sent_today: otps_sent_today, + verified_today: otps_verified_today, + verification_rate: otps_sent_today > 0 ? (otps_verified_today.to_f / otps_sent_today * 100).round(2) : 0 + }, + timestamp: Time.current + } + end + end + end + end +end diff --git a/app/controllers/api/v1/gateway/base_controller.rb b/app/controllers/api/v1/gateway/base_controller.rb new file mode 100644 index 0000000..d435edc --- /dev/null +++ b/app/controllers/api/v1/gateway/base_controller.rb @@ -0,0 +1,10 @@ +module Api + module V1 + module Gateway + class BaseController < ApplicationController + include ApiAuthenticatable + include RateLimitable + end + end + end +end diff --git a/app/controllers/api/v1/gateway/heartbeats_controller.rb b/app/controllers/api/v1/gateway/heartbeats_controller.rb new file mode 100644 index 0000000..9117853 --- /dev/null +++ b/app/controllers/api/v1/gateway/heartbeats_controller.rb @@ -0,0 +1,30 @@ +module Api + module V1 + module Gateway + class HeartbeatsController < BaseController + def create + current_gateway.heartbeat! + + # Update metadata with device info + metadata = { + battery_level: params[:battery_level], + signal_strength: params[:signal_strength], + messages_in_queue: params[:messages_in_queue] + }.compact + + current_gateway.update(metadata: metadata) if metadata.any? + + # Get count of pending messages for this gateway + pending_count = SmsMessage.pending + .where(gateway_id: [nil, current_gateway.id]) + .count + + render json: { + success: true, + pending_messages: pending_count + } + end + end + end + end +end diff --git a/app/controllers/api/v1/gateway/registrations_controller.rb b/app/controllers/api/v1/gateway/registrations_controller.rb new file mode 100644 index 0000000..15d1a02 --- /dev/null +++ b/app/controllers/api/v1/gateway/registrations_controller.rb @@ -0,0 +1,53 @@ +module Api + module V1 + module Gateway + class RegistrationsController < ApplicationController + skip_before_action :authenticate_api_key!, only: [:create] + + def create + device_id = params.require(:device_id) + name = params[:name] || "Gateway #{device_id[0..7]}" + + # Check if gateway already exists + gateway = ::Gateway.find_by(device_id: device_id) + + if gateway + render json: { + success: false, + error: "Gateway already registered" + }, status: :conflict + return + end + + # Create new gateway + gateway = ::Gateway.new( + device_id: device_id, + name: name, + status: "offline" + ) + + raw_key = gateway.generate_api_key! + + render json: { + success: true, + api_key: raw_key, + device_id: gateway.device_id, + websocket_url: websocket_url + }, status: :created + rescue ActionController::ParameterMissing => e + render json: { error: e.message }, status: :bad_request + end + + private + + def websocket_url + if Rails.env.production? + "wss://#{request.host}/cable" + else + "ws://#{request.host}:#{request.port}/cable" + end + end + end + end + end +end diff --git a/app/controllers/api/v1/gateway/sms_controller.rb b/app/controllers/api/v1/gateway/sms_controller.rb new file mode 100644 index 0000000..82399bb --- /dev/null +++ b/app/controllers/api/v1/gateway/sms_controller.rb @@ -0,0 +1,61 @@ +module Api + module V1 + module Gateway + class SmsController < BaseController + # POST /api/v1/gateway/sms/received + def received + sender = params.require(:sender) + message = params.require(:message) + timestamp = params[:timestamp] || Time.current + + # Create inbound SMS message + sms = SmsMessage.create!( + gateway: current_gateway, + direction: "inbound", + phone_number: sender, + message_body: message, + status: "delivered", + delivered_at: timestamp + ) + + # Increment received counter + current_gateway.increment_received_count! + + # Process inbound message asynchronously + ProcessInboundSmsJob.perform_later(sms.id) + + render json: { + success: true, + message_id: sms.message_id + } + rescue ActionController::ParameterMissing => e + render json: { error: e.message }, status: :bad_request + end + + # POST /api/v1/gateway/sms/status + def status + message_id = params.require(:message_id) + status = params.require(:status) + error_message = params[:error_message] + + sms = SmsMessage.find_by!(message_id: message_id) + + case status + when "delivered" + sms.mark_delivered! + when "failed" + sms.mark_failed!(error_message) + # Retry if possible + RetryFailedSmsJob.perform_later(sms.id) if sms.can_retry? + when "sent" + sms.update!(status: "sent", sent_at: Time.current) + end + + render json: { success: true } + rescue ActionController::ParameterMissing => e + render json: { error: e.message }, status: :bad_request + end + end + end + end +end diff --git a/app/controllers/api/v1/otp_controller.rb b/app/controllers/api/v1/otp_controller.rb new file mode 100644 index 0000000..8806127 --- /dev/null +++ b/app/controllers/api/v1/otp_controller.rb @@ -0,0 +1,86 @@ +module Api + module V1 + class OtpController < ApplicationController + include ApiAuthenticatable + include RateLimitable + + # POST /api/v1/otp/send + def send_otp + phone_number = params.require(:phone_number) + purpose = params[:purpose] || "authentication" + expiry_minutes = params[:expiry_minutes]&.to_i || 5 + + # Rate limit by phone number + return unless rate_limit_by_phone!(phone_number, limit: 3, period: 1.hour) + + # Validate phone number + phone = Phonelib.parse(phone_number) + unless phone.valid? + render json: { error: "Invalid phone number format" }, status: :unprocessable_entity + return + end + + # Send OTP + result = OtpCode.send_otp( + phone.e164, + purpose: purpose, + expiry_minutes: expiry_minutes, + ip_address: request.remote_ip + ) + + render json: { + success: true, + expires_at: result[:otp].expires_at, + message_id: result[:sms].message_id + } + rescue ActiveRecord::RecordInvalid => e + # Rate limit error from OTP model + if e.record.errors[:base].any? + render json: { + error: e.record.errors[:base].first + }, status: :too_many_requests + else + render json: { + error: e.message, + details: e.record.errors.full_messages + }, status: :unprocessable_entity + end + rescue ActionController::ParameterMissing => e + render json: { error: e.message }, status: :bad_request + end + + # POST /api/v1/otp/verify + def verify + phone_number = params.require(:phone_number) + code = params.require(:code) + + # Validate phone number + phone = Phonelib.parse(phone_number) + unless phone.valid? + render json: { error: "Invalid phone number format" }, status: :unprocessable_entity + return + end + + # Verify OTP + result = OtpCode.verify(phone.e164, code) + + if result[:success] + render json: { + success: true, + verified: true + } + else + attempts_remaining = 3 - (result[:attempts_remaining] || 0) + render json: { + success: false, + verified: false, + error: result[:error], + attempts_remaining: [attempts_remaining, 0].max + } + end + rescue ActionController::ParameterMissing => e + render json: { error: e.message }, status: :bad_request + end + end + end +end diff --git a/app/controllers/api/v1/sms_controller.rb b/app/controllers/api/v1/sms_controller.rb new file mode 100644 index 0000000..351d1da --- /dev/null +++ b/app/controllers/api/v1/sms_controller.rb @@ -0,0 +1,90 @@ +module Api + module V1 + class SmsController < ApplicationController + include ApiAuthenticatable + include RateLimitable + include Pagy::Backend + + # POST /api/v1/sms/send + def send_sms + return unless rate_limit_by_api_key!(limit: 100, period: 1.minute) + + phone_number = params.require(:to) + message_body = params.require(:message) + + # Validate phone number + phone = Phonelib.parse(phone_number) + unless phone.valid? + render json: { error: "Invalid phone number format" }, status: :unprocessable_entity + return + end + + # Create outbound SMS message + sms = SmsMessage.create!( + direction: "outbound", + phone_number: phone.e164, + message_body: message_body, + status: "queued" + ) + + render json: { + success: true, + message_id: sms.message_id, + status: sms.status + }, status: :accepted + rescue ActionController::ParameterMissing => e + render json: { error: e.message }, status: :bad_request + end + + # GET /api/v1/sms/status/:message_id + def status + message_id = params.require(:message_id) + sms = SmsMessage.find_by!(message_id: message_id) + + render json: { + message_id: sms.message_id, + status: sms.status, + sent_at: sms.sent_at, + delivered_at: sms.delivered_at, + failed_at: sms.failed_at, + error_message: sms.error_message + } + end + + # GET /api/v1/sms/received + def received + query = SmsMessage.inbound.recent + + # Filter by phone number if provided + if params[:phone_number].present? + query = query.where(phone_number: params[:phone_number]) + end + + # Filter by date if provided + if params[:since].present? + since_time = Time.parse(params[:since]) + query = query.where("created_at >= ?", since_time) + end + + # Paginate results + pagy, messages = pagy(query, items: params[:limit] || 50) + + render json: { + messages: messages.map { |sms| + { + message_id: sms.message_id, + from: sms.phone_number, + message: sms.message_body, + received_at: sms.created_at + } + }, + total: pagy.count, + page: pagy.page, + pages: pagy.pages + } + rescue ArgumentError => e + render json: { error: "Invalid date format" }, status: :bad_request + end + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb new file mode 100644 index 0000000..55ef66c --- /dev/null +++ b/app/controllers/application_controller.rb @@ -0,0 +1,22 @@ +class ApplicationController < ActionController::API + rescue_from ActiveRecord::RecordNotFound, with: :render_not_found + rescue_from ActiveRecord::RecordInvalid, with: :render_unprocessable + rescue_from ActionController::ParameterMissing, with: :render_bad_request + + private + + def render_not_found(exception) + render json: { error: exception.message }, status: :not_found + end + + def render_unprocessable(exception) + render json: { + error: exception.message, + details: exception.record.errors.full_messages + }, status: :unprocessable_entity + end + + def render_bad_request(exception) + render json: { error: exception.message }, status: :bad_request + end +end diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/controllers/concerns/api_authenticatable.rb b/app/controllers/concerns/api_authenticatable.rb new file mode 100644 index 0000000..55196c6 --- /dev/null +++ b/app/controllers/concerns/api_authenticatable.rb @@ -0,0 +1,73 @@ +module ApiAuthenticatable + extend ActiveSupport::Concern + + included do + before_action :authenticate_api_key! + end + + private + + def authenticate_api_key! + api_key = extract_api_key + return render_unauthorized("Missing API key") unless api_key + + key_digest = Digest::SHA256.hexdigest(api_key) + + if api_key.start_with?("gw_") + authenticate_gateway(key_digest) + else + authenticate_client_api_key(key_digest) + end + end + + def authenticate_gateway(key_digest) + @current_gateway = Gateway.find_by(api_key_digest: key_digest, active: true) + + unless @current_gateway + render_unauthorized("Invalid gateway API key") + end + end + + def authenticate_client_api_key(key_digest) + @current_api_key = ApiKey.find_by(key_digest: key_digest, active: true) + + unless @current_api_key + render_unauthorized("Invalid API key") + return + end + + # Check if key has expired + if @current_api_key.expires_at.present? && @current_api_key.expires_at < Time.current + render_unauthorized("API key has expired") + return + end + + @current_api_key.touch(:last_used_at) + end + + def extract_api_key + auth_header = request.headers["Authorization"] + return nil unless auth_header + + # Support both "Bearer token" and just "token" + auth_header.sub(/^Bearer\s+/, "") + end + + def render_unauthorized(message = "Unauthorized") + render json: { error: message }, status: :unauthorized + end + + def current_gateway + @current_gateway + end + + def current_api_key + @current_api_key + end + + def require_permission(permission) + unless @current_api_key&.can?(permission) + render json: { error: "Insufficient permissions" }, status: :forbidden + end + end +end diff --git a/app/controllers/concerns/rate_limitable.rb b/app/controllers/concerns/rate_limitable.rb new file mode 100644 index 0000000..d8c4150 --- /dev/null +++ b/app/controllers/concerns/rate_limitable.rb @@ -0,0 +1,54 @@ +module RateLimitable + extend ActiveSupport::Concern + + private + + def rate_limit_check!(key, limit:, period:) + cache_key = "rate_limit:#{key}" + count = Rails.cache.read(cache_key) || 0 + + if count >= limit + render_rate_limit_exceeded(period) + return false + end + + Rails.cache.write(cache_key, count + 1, expires_in: period) + true + end + + def rate_limit_increment(key, period:) + cache_key = "rate_limit:#{key}" + current_count = Rails.cache.read(cache_key) || 0 + Rails.cache.write(cache_key, current_count + 1, expires_in: period) + end + + def rate_limit_reset(key) + cache_key = "rate_limit:#{key}" + Rails.cache.delete(cache_key) + end + + def render_rate_limit_exceeded(retry_after) + render json: { + error: "Rate limit exceeded", + retry_after: retry_after.to_i + }, status: :too_many_requests + end + + # Rate limit based on IP address + def rate_limit_by_ip!(limit:, period:) + ip_address = request.remote_ip + rate_limit_check!("ip:#{ip_address}", limit: limit, period: period) + end + + # Rate limit based on API key + def rate_limit_by_api_key!(limit:, period:) + return true unless @current_api_key + + rate_limit_check!("api_key:#{@current_api_key.id}", limit: limit, period: period) + end + + # Rate limit based on phone number + def rate_limit_by_phone!(phone_number, limit:, period:) + rate_limit_check!("phone:#{phone_number}", limit: limit, period: period) + end +end diff --git a/app/helpers/admin_helper.rb b/app/helpers/admin_helper.rb new file mode 100644 index 0000000..b7b8929 --- /dev/null +++ b/app/helpers/admin_helper.rb @@ -0,0 +1,9 @@ +module AdminHelper + def current_admin + @current_admin ||= AdminUser.find_by(id: session[:admin_id]) if session[:admin_id] + end + + def logged_in? + current_admin.present? + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb new file mode 100644 index 0000000..6fe8ed4 --- /dev/null +++ b/app/helpers/application_helper.rb @@ -0,0 +1,12 @@ +module ApplicationHelper + include Pagy::Frontend + + # Admin authentication helpers + def current_admin + @current_admin ||= AdminUser.find_by(id: session[:admin_id]) if session[:admin_id] + end + + def logged_in? + current_admin.present? + end +end diff --git a/app/javascript/application.js b/app/javascript/application.js new file mode 100644 index 0000000..0d7b494 --- /dev/null +++ b/app/javascript/application.js @@ -0,0 +1,3 @@ +// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails +import "@hotwired/turbo-rails" +import "controllers" diff --git a/app/javascript/controllers/application.js b/app/javascript/controllers/application.js new file mode 100644 index 0000000..1213e85 --- /dev/null +++ b/app/javascript/controllers/application.js @@ -0,0 +1,9 @@ +import { Application } from "@hotwired/stimulus" + +const application = Application.start() + +// Configure Stimulus development experience +application.debug = false +window.Stimulus = application + +export { application } diff --git a/app/javascript/controllers/hello_controller.js b/app/javascript/controllers/hello_controller.js new file mode 100644 index 0000000..5975c07 --- /dev/null +++ b/app/javascript/controllers/hello_controller.js @@ -0,0 +1,7 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + connect() { + this.element.textContent = "Hello World!" + } +} diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js new file mode 100644 index 0000000..1156bf8 --- /dev/null +++ b/app/javascript/controllers/index.js @@ -0,0 +1,4 @@ +// Import and register all your controllers from the importmap via controllers/**/*_controller +import { application } from "controllers/application" +import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" +eagerLoadControllersFrom("controllers", application) diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb new file mode 100644 index 0000000..d394c3d --- /dev/null +++ b/app/jobs/application_job.rb @@ -0,0 +1,7 @@ +class ApplicationJob < ActiveJob::Base + # Automatically retry jobs that encountered a deadlock + # retry_on ActiveRecord::Deadlocked + + # Most jobs are safe to ignore if the underlying records are no longer available + # discard_on ActiveJob::DeserializationError +end diff --git a/app/jobs/check_gateway_health_job.rb b/app/jobs/check_gateway_health_job.rb new file mode 100644 index 0000000..e973e91 --- /dev/null +++ b/app/jobs/check_gateway_health_job.rb @@ -0,0 +1,14 @@ +class CheckGatewayHealthJob < ApplicationJob + queue_as :default + + def perform + # Mark gateways as offline if no heartbeat in last 2 minutes + offline_count = Gateway.where("last_heartbeat_at < ?", 2.minutes.ago) + .where.not(status: "offline") + .update_all(status: "offline") + + if offline_count > 0 + Rails.logger.warn("Marked #{offline_count} gateways as offline due to missing heartbeat") + end + end +end diff --git a/app/jobs/cleanup_expired_otps_job.rb b/app/jobs/cleanup_expired_otps_job.rb new file mode 100644 index 0000000..60a29b8 --- /dev/null +++ b/app/jobs/cleanup_expired_otps_job.rb @@ -0,0 +1,8 @@ +class CleanupExpiredOtpsJob < ApplicationJob + queue_as :low_priority + + def perform + deleted_count = OtpCode.cleanup_expired! + Rails.logger.info("Cleaned up #{deleted_count} expired OTP codes") + end +end diff --git a/app/jobs/process_inbound_sms_job.rb b/app/jobs/process_inbound_sms_job.rb new file mode 100644 index 0000000..47c7880 --- /dev/null +++ b/app/jobs/process_inbound_sms_job.rb @@ -0,0 +1,57 @@ +class ProcessInboundSmsJob < ApplicationJob + queue_as :default + + def perform(sms_message_id) + sms = SmsMessage.find(sms_message_id) + + # Skip if not inbound + return unless sms.inbound? + + # Trigger webhooks for sms_received event + trigger_webhooks(sms) + + # Check if this is an OTP reply + check_otp_reply(sms) + + Rails.logger.info("Processed inbound SMS #{sms.message_id} from #{sms.phone_number}") + rescue ActiveRecord::RecordNotFound => e + Rails.logger.error("SMS message not found: #{e.message}") + rescue StandardError => e + Rails.logger.error("Failed to process inbound SMS #{sms_message_id}: #{e.message}") + end + + private + + def trigger_webhooks(sms) + webhooks = WebhookConfig.for_event_type("sms_received") + + webhooks.each do |webhook| + payload = { + event: "sms_received", + message_id: sms.message_id, + from: sms.phone_number, + message: sms.message_body, + received_at: sms.created_at, + gateway_id: sms.gateway&.device_id + } + + webhook.trigger(payload) + end + end + + def check_otp_reply(sms) + # Extract potential OTP code from message (6 digits) + code_match = sms.message_body.match(/\b\d{6}\b/) + return unless code_match + + code = code_match[0] + + # Try to verify if there's a pending OTP for this phone number + otp = OtpCode.valid_codes.find_by(phone_number: sms.phone_number, code: code) + + if otp + otp.update!(verified: true, verified_at: Time.current) + Rails.logger.info("Auto-verified OTP for #{sms.phone_number}") + end + end +end diff --git a/app/jobs/reset_daily_counters_job.rb b/app/jobs/reset_daily_counters_job.rb new file mode 100644 index 0000000..6fcdc1c --- /dev/null +++ b/app/jobs/reset_daily_counters_job.rb @@ -0,0 +1,8 @@ +class ResetDailyCountersJob < ApplicationJob + queue_as :default + + def perform + Gateway.reset_daily_counters! + Rails.logger.info("Reset daily counters for all gateways") + end +end diff --git a/app/jobs/retry_failed_sms_job.rb b/app/jobs/retry_failed_sms_job.rb new file mode 100644 index 0000000..e8dd7e4 --- /dev/null +++ b/app/jobs/retry_failed_sms_job.rb @@ -0,0 +1,22 @@ +class RetryFailedSmsJob < ApplicationJob + queue_as :default + + def perform(sms_message_id) + sms = SmsMessage.find(sms_message_id) + + # Only retry if message can be retried + return unless sms.can_retry? + + # Reset status to queued and increment retry count + sms.increment_retry! + sms.update!(status: "queued", error_message: nil) + + # Re-queue for sending with exponential backoff + wait_time = (2 ** sms.retry_count).minutes + SendSmsJob.set(wait: wait_time).perform_later(sms.id) + + Rails.logger.info("Retrying failed SMS #{sms.message_id} (attempt #{sms.retry_count})") + rescue ActiveRecord::RecordNotFound => e + Rails.logger.error("SMS message not found: #{e.message}") + end +end diff --git a/app/jobs/send_sms_job.rb b/app/jobs/send_sms_job.rb new file mode 100644 index 0000000..f640b86 --- /dev/null +++ b/app/jobs/send_sms_job.rb @@ -0,0 +1,41 @@ +class SendSmsJob < ApplicationJob + queue_as :default + retry_on StandardError, wait: :exponentially_longer, attempts: 3 + + def perform(sms_message_id) + sms = SmsMessage.find(sms_message_id) + + # Skip if already sent or not outbound + return unless sms.outbound? && sms.status == "queued" + + # Find available gateway + gateway = Gateway.available_for_sending + + unless gateway + Rails.logger.warn("No available gateway for message #{sms.message_id}") + sms.update!(status: "pending") + # Retry later + SendSmsJob.set(wait: 1.minute).perform_later(sms_message_id) + return + end + + # Update message status + sms.mark_sent!(gateway) + + # Broadcast message to gateway via WebSocket + GatewayChannel.broadcast_to(gateway, { + action: "send_sms", + message_id: sms.message_id, + recipient: sms.phone_number, + message: sms.message_body + }) + + Rails.logger.info("Sent SMS #{sms.message_id} to gateway #{gateway.device_id}") + rescue ActiveRecord::RecordNotFound => e + Rails.logger.error("SMS message not found: #{e.message}") + rescue StandardError => e + Rails.logger.error("Failed to send SMS #{sms_message_id}: #{e.message}") + sms&.increment_retry! + raise + end +end diff --git a/app/jobs/trigger_webhook_job.rb b/app/jobs/trigger_webhook_job.rb new file mode 100644 index 0000000..054d4cf --- /dev/null +++ b/app/jobs/trigger_webhook_job.rb @@ -0,0 +1,31 @@ +class TriggerWebhookJob < ApplicationJob + queue_as :default + retry_on StandardError, wait: :exponentially_longer, attempts: 3 + + def perform(webhook_config_id, payload) + webhook = WebhookConfig.find(webhook_config_id) + + # Skip if webhook is not active + return unless webhook.active? + + success = webhook.execute(payload) + + if success + Rails.logger.info("Webhook #{webhook.name} triggered successfully") + else + Rails.logger.warn("Webhook #{webhook.name} failed") + raise StandardError, "Webhook execution failed" if attempts_count < webhook.retry_count + end + rescue ActiveRecord::RecordNotFound => e + Rails.logger.error("Webhook config not found: #{e.message}") + rescue StandardError => e + Rails.logger.error("Webhook trigger failed: #{e.message}") + raise if attempts_count < (webhook&.retry_count || 3) + end + + private + + def attempts_count + executions + end +end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb new file mode 100644 index 0000000..3c34c81 --- /dev/null +++ b/app/mailers/application_mailer.rb @@ -0,0 +1,4 @@ +class ApplicationMailer < ActionMailer::Base + default from: "from@example.com" + layout "mailer" +end diff --git a/app/models/admin_user.rb b/app/models/admin_user.rb new file mode 100644 index 0000000..83391b3 --- /dev/null +++ b/app/models/admin_user.rb @@ -0,0 +1,11 @@ +class AdminUser < ApplicationRecord + has_secure_password + + validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } + validates :name, presence: true + validates :password, length: { minimum: 8 }, if: -> { password.present? } + + def update_last_login! + update!(last_login_at: Time.current) + end +end diff --git a/app/models/api_key.rb b/app/models/api_key.rb new file mode 100644 index 0000000..7eda901 --- /dev/null +++ b/app/models/api_key.rb @@ -0,0 +1,72 @@ +class ApiKey < ApplicationRecord + # Normalize permissions to always be a Hash + attribute :permissions, :jsonb, default: {} + + before_validation :ensure_permissions_is_hash + + validates :name, presence: true + validates :key_digest, presence: true, uniqueness: true + validates :key_prefix, presence: true + + scope :active_keys, -> { where(active: true) } + scope :expired, -> { where("expires_at IS NOT NULL AND expires_at < ?", Time.current) } + scope :valid, -> { active_keys.where("expires_at IS NULL OR expires_at > ?", Time.current) } + + # Generate a new API key + def self.generate!(name:, permissions: {}, expires_at: nil) + raw_key = "api_live_#{SecureRandom.hex(32)}" + key_digest = Digest::SHA256.hexdigest(raw_key) + key_prefix = raw_key[0..11] # First 12 chars for identification + + api_key = create!( + name: name, + key_digest: key_digest, + key_prefix: key_prefix, + permissions: permissions, + expires_at: expires_at + ) + + { api_key: api_key, raw_key: raw_key } + end + + # Authenticate with a raw key + def self.authenticate(raw_key) + return nil unless raw_key.present? + + key_digest = Digest::SHA256.hexdigest(raw_key) + api_key = valid.find_by(key_digest: key_digest) + + if api_key + api_key.touch(:last_used_at) + end + + api_key + end + + # Check if API key is still active and not expired + def active_and_valid? + active && (expires_at.nil? || expires_at > Time.current) + end + + # Check if API key has specific permission + def can?(permission) + permissions.fetch(permission.to_s, false) + end + + # Revoke API key + def revoke! + update!(active: false) + end + + # Deactivate expired keys + def self.deactivate_expired! + expired.update_all(active: false) + end + + private + + def ensure_permissions_is_hash + self.permissions = {} if permissions.nil? + self.permissions = {} unless permissions.is_a?(Hash) + end +end diff --git a/app/models/application_record.rb b/app/models/application_record.rb new file mode 100644 index 0000000..b63caeb --- /dev/null +++ b/app/models/application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class +end diff --git a/app/models/concerns/.keep b/app/models/concerns/.keep new file mode 100644 index 0000000..e69de29 diff --git a/app/models/concerns/metrics.rb b/app/models/concerns/metrics.rb new file mode 100644 index 0000000..1f181cc --- /dev/null +++ b/app/models/concerns/metrics.rb @@ -0,0 +1,81 @@ +module Metrics + extend ActiveSupport::Concern + + class_methods do + def increment_counter(metric_name, amount = 1) + cache_key = "metrics:#{metric_name}" + current_value = Rails.cache.read(cache_key) || 0 + Rails.cache.write(cache_key, current_value + amount) + end + + def decrement_counter(metric_name, amount = 1) + cache_key = "metrics:#{metric_name}" + current_value = Rails.cache.read(cache_key) || 0 + new_value = [current_value - amount, 0].max + Rails.cache.write(cache_key, new_value) + end + + def get_counter(metric_name) + cache_key = "metrics:#{metric_name}" + Rails.cache.read(cache_key) || 0 + end + + def reset_counter(metric_name) + cache_key = "metrics:#{metric_name}" + Rails.cache.delete(cache_key) + end + + def set_gauge(metric_name, value) + cache_key = "metrics:gauge:#{metric_name}" + Rails.cache.write(cache_key, value) + end + + def get_gauge(metric_name) + cache_key = "metrics:gauge:#{metric_name}" + Rails.cache.read(cache_key) + end + + # Record timing metrics + def record_timing(metric_name, duration_ms) + cache_key = "metrics:timing:#{metric_name}" + timings = Rails.cache.read(cache_key) || [] + timings << duration_ms + # Keep only last 100 measurements + timings = timings.last(100) + Rails.cache.write(cache_key, timings) + end + + def get_timing_stats(metric_name) + cache_key = "metrics:timing:#{metric_name}" + timings = Rails.cache.read(cache_key) || [] + + return nil if timings.empty? + + { + count: timings.size, + avg: timings.sum / timings.size.to_f, + min: timings.min, + max: timings.max, + p95: percentile(timings, 95), + p99: percentile(timings, 99) + } + end + + private + + def percentile(array, percent) + return nil if array.empty? + + sorted = array.sort + k = (percent / 100.0) * (sorted.length - 1) + f = k.floor + c = k.ceil + + if f == c + sorted[k] + else + sorted[f] * (c - k) + sorted[c] * (k - f) + end + end + end +end diff --git a/app/models/gateway.rb b/app/models/gateway.rb new file mode 100644 index 0000000..1e30a62 --- /dev/null +++ b/app/models/gateway.rb @@ -0,0 +1,68 @@ +class Gateway < ApplicationRecord + # Normalize metadata to always be a Hash + attribute :metadata, :jsonb, default: {} + + has_many :sms_messages, dependent: :nullify + + before_validation :ensure_metadata_is_hash + + validates :device_id, presence: true, uniqueness: true + validates :api_key_digest, presence: true + validates :status, inclusion: { in: %w[online offline error] } + + scope :online, -> { where(status: "online") } + scope :offline, -> { where(status: "offline") } + scope :active, -> { where(active: true) } + scope :by_priority, -> { order(priority: :desc, id: :asc) } + + # Find the best available gateway for sending messages + def self.available_for_sending + online.active.by_priority.first + end + + # Generate a new API key for the gateway + def generate_api_key! + raw_key = "gw_live_#{SecureRandom.hex(32)}" + self.api_key_digest = Digest::SHA256.hexdigest(raw_key) + save! + raw_key + end + + # Check if gateway is currently online based on heartbeat + def online? + status == "online" && last_heartbeat_at.present? && last_heartbeat_at > 2.minutes.ago + end + + # Update heartbeat timestamp and status + def heartbeat! + update!(status: "online", last_heartbeat_at: Time.current) + end + + # Mark gateway as offline + def mark_offline! + update!(status: "offline") + end + + # Increment message counters + def increment_sent_count! + increment!(:messages_sent_today) + increment!(:total_messages_sent) + end + + def increment_received_count! + increment!(:messages_received_today) + increment!(:total_messages_received) + end + + # Reset daily counters (called by scheduled job) + def self.reset_daily_counters! + update_all(messages_sent_today: 0, messages_received_today: 0) + end + + private + + def ensure_metadata_is_hash + self.metadata = {} if metadata.nil? + self.metadata = {} unless metadata.is_a?(Hash) + end +end diff --git a/app/models/otp_code.rb b/app/models/otp_code.rb new file mode 100644 index 0000000..1cfa099 --- /dev/null +++ b/app/models/otp_code.rb @@ -0,0 +1,118 @@ +class OtpCode < ApplicationRecord + # Normalize metadata to always be a Hash + attribute :metadata, :jsonb, default: {} + + validates :phone_number, presence: true + validates :code, presence: true, length: { is: 6 } + validates :expires_at, presence: true + validates :purpose, presence: true + + validate :phone_number_format + validate :rate_limit_check, on: :create + + before_validation :ensure_metadata_is_hash + before_validation :generate_code, on: :create, unless: :code? + before_validation :set_expiry, on: :create, unless: :expires_at? + before_validation :normalize_phone_number + + scope :valid_codes, -> { where(verified: false).where("expires_at > ?", Time.current) } + scope :expired, -> { where("expires_at <= ?", Time.current) } + scope :verified_codes, -> { where(verified: true) } + + # Verify an OTP code + def self.verify(phone_number, code) + normalized_phone = normalize_phone_number_string(phone_number) + otp = valid_codes.find_by(phone_number: normalized_phone, code: code) + + return { success: false, error: "Invalid or expired OTP", attempts_remaining: 0 } unless otp + + otp.increment!(:attempts) + + # Lock out after 3 failed attempts + if otp.attempts > 3 + otp.update!(expires_at: Time.current) + return { success: false, error: "Too many attempts. OTP expired.", attempts_remaining: 0 } + end + + # Successfully verified + otp.update!(verified: true, verified_at: Time.current) + { success: true, verified: true } + end + + # Generate and send OTP + def self.send_otp(phone_number, purpose: "authentication", expiry_minutes: 5, ip_address: nil) + normalized_phone = normalize_phone_number_string(phone_number) + + otp = create!( + phone_number: normalized_phone, + purpose: purpose, + expires_at: expiry_minutes.minutes.from_now, + ip_address: ip_address + ) + + # Create SMS message for sending + sms = SmsMessage.create!( + direction: "outbound", + phone_number: normalized_phone, + message_body: "Your OTP code is: #{otp.code}. Valid for #{expiry_minutes} minutes. Do not share this code." + ) + + { otp: otp, sms: sms } + end + + # Clean up expired OTP codes + def self.cleanup_expired! + expired.where(verified: false).delete_all + end + + # Check if OTP is still active and usable + def active_and_usable? + !verified && expires_at > Time.current && attempts < 3 + end + + private + + def generate_code + self.code = format("%06d", SecureRandom.random_number(1_000_000)) + end + + def set_expiry + self.expires_at = 5.minutes.from_now + end + + def normalize_phone_number + return unless phone_number.present? + self.phone_number = self.class.normalize_phone_number_string(phone_number) + end + + def self.normalize_phone_number_string(number) + number.gsub(/[^\d+]/, "") + end + + def phone_number_format + return unless phone_number.present? + + phone = Phonelib.parse(phone_number) + unless phone.valid? + errors.add(:phone_number, "is not a valid phone number") + end + end + + def rate_limit_check + return unless phone_number.present? + + # Max 3 OTP per phone per hour + recent_count = OtpCode.where(phone_number: phone_number) + .where("created_at > ?", 1.hour.ago) + .count + + if recent_count >= 3 + errors.add(:base, "Rate limit exceeded. Maximum 3 OTP codes per hour.") + end + end + + def ensure_metadata_is_hash + self.metadata = {} if metadata.nil? + self.metadata = {} unless metadata.is_a?(Hash) + end +end diff --git a/app/models/sms_message.rb b/app/models/sms_message.rb new file mode 100644 index 0000000..11a46ca --- /dev/null +++ b/app/models/sms_message.rb @@ -0,0 +1,110 @@ +class SmsMessage < ApplicationRecord + # Normalize metadata to always be a Hash + attribute :metadata, :jsonb, default: {} + + belongs_to :gateway, optional: true + + validates :phone_number, presence: true + validates :message_body, presence: true, length: { maximum: 1600 } + validates :direction, presence: true, inclusion: { in: %w[inbound outbound] } + validates :message_id, presence: true, uniqueness: true + validates :status, inclusion: { in: %w[pending queued sent delivered failed] } + + validate :phone_number_format + + before_validation :ensure_metadata_is_hash + before_validation :generate_message_id, on: :create + before_validation :normalize_phone_number + after_create_commit :enqueue_sending, if: :outbound? + + scope :pending, -> { where(status: "pending") } + scope :queued, -> { where(status: "queued") } + scope :sent, -> { where(status: "sent") } + scope :delivered, -> { where(status: "delivered") } + scope :failed, -> { where(status: "failed") } + scope :inbound, -> { where(direction: "inbound") } + scope :outbound, -> { where(direction: "outbound") } + scope :recent, -> { order(created_at: :desc) } + + # Check message direction + def outbound? + direction == "outbound" + end + + def inbound? + direction == "inbound" + end + + # Check if message can be retried + def can_retry? + failed? && retry_count < 3 + end + + # Mark message as sent + def mark_sent!(gateway) + update!( + status: "sent", + gateway: gateway, + sent_at: Time.current + ) + gateway.increment_sent_count! + end + + # Mark message as delivered + def mark_delivered! + update!( + status: "delivered", + delivered_at: Time.current + ) + end + + # Mark message as failed + def mark_failed!(error_msg = nil) + update!( + status: "failed", + failed_at: Time.current, + error_message: error_msg + ) + end + + # Increment retry counter + def increment_retry! + increment!(:retry_count) + end + + # Check if message status is failed + def failed? + status == "failed" + end + + private + + def generate_message_id + self.message_id ||= "msg_#{SecureRandom.hex(16)}" + end + + def normalize_phone_number + return unless phone_number.present? + + # Remove spaces and special characters + self.phone_number = phone_number.gsub(/[^\d+]/, "") + end + + def phone_number_format + return unless phone_number.present? + + phone = Phonelib.parse(phone_number) + unless phone.valid? + errors.add(:phone_number, "is not a valid phone number") + end + end + + def enqueue_sending + SendSmsJob.perform_later(id) + end + + def ensure_metadata_is_hash + self.metadata = {} if metadata.nil? + self.metadata = {} unless metadata.is_a?(Hash) + end +end diff --git a/app/models/webhook_config.rb b/app/models/webhook_config.rb new file mode 100644 index 0000000..fc596a0 --- /dev/null +++ b/app/models/webhook_config.rb @@ -0,0 +1,54 @@ +class WebhookConfig < ApplicationRecord + validates :name, presence: true + validates :url, presence: true, format: { with: URI::DEFAULT_PARSER.make_regexp(%w[http https]) } + validates :event_type, presence: true, inclusion: { in: %w[sms_received sms_sent sms_failed] } + validates :timeout, numericality: { greater_than: 0, less_than_or_equal_to: 120 } + validates :retry_count, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 5 } + + scope :active_webhooks, -> { where(active: true) } + scope :for_event, ->(event_type) { where(event_type: event_type) } + + # Trigger webhook with payload + def trigger(payload) + return unless active? + + TriggerWebhookJob.perform_later(id, payload) + end + + # Execute webhook HTTP request + def execute(payload) + headers = { + "Content-Type" => "application/json", + "User-Agent" => "MySMSAPI-Webhook/1.0" + } + + # Add signature if secret key is present + if secret_key.present? + signature = generate_signature(payload) + headers["X-Webhook-Signature"] = signature + end + + response = HTTParty.post( + url, + body: payload.to_json, + headers: headers, + timeout: timeout + ) + + response.success? + rescue StandardError => e + Rails.logger.error("Webhook execution failed: #{e.message}") + false + end + + # Find active webhooks for a specific event + def self.for_event_type(event_type) + active_webhooks.for_event(event_type) + end + + private + + def generate_signature(payload) + OpenSSL::HMAC.hexdigest("SHA256", secret_key, payload.to_json) + end +end diff --git a/app/views/admin/api_keys/index.html.erb b/app/views/admin/api_keys/index.html.erb new file mode 100644 index 0000000..b4f0a92 --- /dev/null +++ b/app/views/admin/api_keys/index.html.erb @@ -0,0 +1,101 @@ +
+
+
+

API Keys

+

Manage API keys for client access and authentication

+
+ <%= link_to new_admin_api_key_path, class: "mt-4 sm:mt-0 inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 transition-all" do %> + + Create New API Key + <% end %> +
+ +
+ <% if @api_keys.any? %> +
+ + + + + + + + + + + + + + <% @api_keys.each do |api_key| %> + + + + + + + + + + <% end %> + +
NameKey PrefixPermissionsStatusLast UsedCreatedActions
+
<%= api_key.name %>
+
+ <%= api_key.key_prefix %>... + +
+ <% perms = api_key.permissions || {} %> + <% perms = {} unless perms.is_a?(Hash) %> + <% if perms.any? %> + <% perms.select { |_, v| v }.keys.each do |perm| %> + + <%= perm.to_s.humanize %> + + <% end %> + <% else %> + None + <% end %> +
+
+ <% if api_key.active_and_valid? %> + + + Active + + <% elsif !api_key.active %> + Revoked + <% else %> + Expired + <% end %> + + <% if api_key.last_used_at %> + <%= time_ago_in_words(api_key.last_used_at) %> ago + <% else %> + Never + <% end %> + + <%= api_key.created_at.strftime("%Y-%m-%d") %> + + <% if api_key.active %> + <%= button_to admin_api_key_path(api_key), method: :delete, class: "inline-flex items-center gap-1 text-red-600 hover:text-red-900", data: { confirm: "Are you sure you want to revoke this API key?" } do %> + + Revoke + <% end %> + <% end %> +
+
+ <% else %> +
+ +

No API keys

+

Get started by creating a new API key.

+
+ <%= link_to new_admin_api_key_path, class: "inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500" do %> + + Create API Key + <% end %> +
+
+ <% end %> +
+
diff --git a/app/views/admin/api_keys/new.html.erb b/app/views/admin/api_keys/new.html.erb new file mode 100644 index 0000000..c81268b --- /dev/null +++ b/app/views/admin/api_keys/new.html.erb @@ -0,0 +1,105 @@ +
+ +
+ <%= link_to admin_api_keys_path, class: "inline-flex items-center gap-2 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors" do %> + + Back to API Keys + <% end %> +
+ +
+

Create New API Key

+

Generate a new API key with specific permissions for your application.

+
+ + +
+ <%= form_with url: admin_api_keys_path, method: :post, local: true, class: "space-y-6" do |f| %> + +
+ <%= label_tag "api_key[name]", "Name", class: "block text-sm font-medium text-gray-700" %> +
+
+ +
+ <%= text_field_tag "api_key[name]", nil, + class: "block w-full pl-10 rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm py-3", + placeholder: "My Application", + required: true %> +
+

A descriptive name to identify this API key

+
+ + +
+ +
+ + + + + + + +
+
+ + +
+ <%= label_tag "api_key[expires_at]", "Expiration Date (Optional)", class: "block text-sm font-medium text-gray-700" %> +
+
+ +
+ <%= datetime_local_field_tag "api_key[expires_at]", nil, + class: "block w-full pl-10 rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm py-3" %> +
+

Leave empty for no expiration

+
+ + +
+ <%= submit_tag "Create API Key", + class: "inline-flex justify-center items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 transition-all duration-200" %> + <%= link_to "Cancel", admin_api_keys_path, + class: "inline-flex justify-center items-center gap-2 rounded-lg bg-gray-100 px-6 py-3 text-sm font-semibold text-gray-700 hover:bg-gray-200 transition-all duration-200" %> +
+ <% end %> +
+
diff --git a/app/views/admin/api_keys/show.html.erb b/app/views/admin/api_keys/show.html.erb new file mode 100644 index 0000000..70f0647 --- /dev/null +++ b/app/views/admin/api_keys/show.html.erb @@ -0,0 +1,139 @@ +
+ +
+

API Key Created Successfully!

+

Your new API key has been generated and is ready to use.

+
+ + +
+
+
+ +
+
+

Important: Save this key now!

+

+ This is the only time you'll be able to see the full API key. Make sure to copy it and store it securely. + If you lose it, you'll need to generate a new key. +

+
+
+
+ + +
+
+

Your New API Key

+ +
+ +
+ <%= @raw_key %> +
+
+ + +
+

API Key Details

+ +
+
+
Name
+
<%= @api_key.name %>
+
+ +
+
Key Prefix
+
+ <%= @api_key.key_prefix %>... +
+
+ +
+
Permissions
+
+
+ <% perms = @api_key.permissions || {} %> + <% perms = {} unless perms.is_a?(Hash) %> + <% perms.select { |_, v| v }.keys.each do |perm| %> + + + <%= perm.to_s.humanize %> + + <% end %> +
+
+
+ +
+
Expiration
+
+ <% if @api_key.expires_at %> +
+ + <%= @api_key.expires_at.strftime("%B %d, %Y at %l:%M %p") %> +
+ <% else %> + + + Never expires + + <% end %> +
+
+ +
+
Status
+
+ + + Active + +
+
+ +
+
Created
+
+ <%= @api_key.created_at.strftime("%B %d, %Y at %l:%M %p") %> +
+
+
+
+ + +
+ <%= link_to admin_api_keys_path, + class: "inline-flex items-center gap-2 rounded-lg bg-gray-100 px-6 py-3 text-sm font-semibold text-gray-700 hover:bg-gray-200 transition-all duration-200" do %> + + Back to API Keys + <% end %> +
+
+ + diff --git a/app/views/admin/api_tester/index.html.erb b/app/views/admin/api_tester/index.html.erb new file mode 100644 index 0000000..076fb39 --- /dev/null +++ b/app/views/admin/api_tester/index.html.erb @@ -0,0 +1,466 @@ +
+ +
+

API Tester

+

Test all API endpoints with interactive forms. View request/response in real-time.

+
+ + +
+

Authentication

+ +
+ +
+ +
+ <% if @api_keys.any? %> + <% @api_keys.each do |key| %> +
+ + <%= key.name %> + + <%= key.key_prefix %>*** +
+ <% end %> +

+ + Raw API keys are only shown once during creation for security. Enter your saved API key below. +

+ <% else %> +

No API keys found. Create one first.

+ <% end %> +
+
+ + +
+ + +

+ Client API keys start with api_live_, + Gateway keys start with gw_live_ +

+
+ + +
+ + +
+
+
+ + +
+ +
+ +
+ + +
+ +
+

Send SMS

+

POST /api/v1/sms/send

+ +
+
+ + +
+
+ + +
+ +
+
+ + +
+

Check SMS Status

+

GET /api/v1/sms/status/:message_id

+ +
+
+ + +
+ +
+
+ + +
+

Send OTP

+

POST /api/v1/otp/send

+ +
+
+ + +
+ +
+
+ + +
+

Verify OTP

+

POST /api/v1/otp/verify

+ +
+
+ + +
+
+ + +
+ +
+
+ + +
+

Register Gateway

+

POST /api/v1/gateway/register

+ +
+
+ + +
+
+ + +
+ +
+
+ + +
+

Send Heartbeat

+

POST /api/v1/gateway/heartbeat

+ +
+
+ + +
+
+ + +
+ +
+
+
+
+ + +
+ +
+
+

Request

+ +
+
Request will appear here...
+
+ + +
+
+

Response

+ +
+
Response will appear here...
+
+
+ + +
+
+
+ +
+
+

API Testing Tips

+
    +
  • Select an API key from the dropdown or enter a custom key
  • +
  • All requests use the Authorization header with Bearer token
  • +
  • Gateway endpoints require gateway API keys (gw_live_...)
  • +
  • Client endpoints require client API keys (api_live_...)
  • +
  • View full request and response in real-time
  • +
+
+
+
+
+ + + + diff --git a/app/views/admin/dashboard/index.html.erb b/app/views/admin/dashboard/index.html.erb new file mode 100644 index 0000000..65bf625 --- /dev/null +++ b/app/views/admin/dashboard/index.html.erb @@ -0,0 +1,230 @@ +
+ +
+

Dashboard

+

Welcome back! Here's what's happening with your SMS gateway today.

+
+ + +
+ +
+
+
+ +
+

Gateways

+
+
+

<%= @stats[:total_gateways] %>

+

+ <%= @stats[:online_gateways] %> online +

+
+
+ + +
+
+
+ +
+

API Keys

+
+
+

<%= @stats[:active_api_keys] %>

+

+ of <%= @stats[:total_api_keys] %> total +

+
+
+ + +
+
+
+ +
+

Messages Today

+
+
+

<%= @stats[:messages_today] %>

+
+
+ + <%= @stats[:messages_sent_today] %> sent + + + <%= @stats[:messages_received_today] %> received + +
+
+ + +
+
+
+ +
+

Failed Today

+
+
+

<%= @stats[:failed_messages_today] %>

+

+ <%= @stats[:pending_messages] %> pending +

+
+
+
+ + +
+
+

Recent Messages

+

Latest SMS activity across all gateways

+
+
+ <% if @recent_messages.any? %> +
+ + + + + + + + + + + + + <% @recent_messages.each do |msg| %> + + + + + + + + + <% end %> + +
Message IDPhone NumberDirectionStatusGatewayCreated
+ <%= msg.message_id[0..15] %>... + <%= msg.phone_number %> + <% if msg.direction == "outbound" %> + + Outbound + + <% else %> + + Inbound + + <% end %> + + <% case msg.status %> + <% when "delivered" %> + Delivered + <% when "sent" %> + Sent + <% when "failed" %> + Failed + <% when "pending" %> + Pending + <% else %> + <%= msg.status.titleize %> + <% end %> + <%= msg.gateway&.name || "-" %><%= time_ago_in_words(msg.created_at) %> ago
+
+
+ <%= link_to admin_logs_path, class: "inline-flex items-center gap-2 text-sm font-semibold text-blue-600 hover:text-blue-500" do %> + View all logs + + <% end %> +
+ <% else %> +
+ +

No messages yet

+
+ <% end %> +
+
+ + +
+
+

Gateway Status

+

Active gateway devices and their performance

+
+
+ <% if @recent_gateways.any? %> +
+ + + + + + + + + + + + <% @recent_gateways.each do |gateway| %> + + + + + + + + <% end %> + +
NameDevice IDStatusMessages TodayLast Heartbeat
+ <%= link_to gateway.name, admin_gateway_path(gateway), class: "text-sm font-medium text-blue-600 hover:text-blue-500" %> + + <%= gateway.device_id %> + + <% if gateway.status == "online" %> + + + Online + + <% else %> + + + Offline + + <% end %> + + + <%= gateway.messages_sent_today %> + + | + + <%= gateway.messages_received_today %> + + + <% if gateway.last_heartbeat_at %> + <%= time_ago_in_words(gateway.last_heartbeat_at) %> ago + <% else %> + Never + <% end %> +
+
+
+ <%= link_to admin_gateways_path, class: "inline-flex items-center gap-2 text-sm font-semibold text-blue-600 hover:text-blue-500" do %> + View all gateways + + <% end %> +
+ <% else %> +
+ +

No gateways registered yet

+
+ <% end %> +
+
+
diff --git a/app/views/admin/gateways/index.html.erb b/app/views/admin/gateways/index.html.erb new file mode 100644 index 0000000..06622f2 --- /dev/null +++ b/app/views/admin/gateways/index.html.erb @@ -0,0 +1,168 @@ +
+ +
+
+

Gateways

+

Manage your SMS gateway devices and monitor their status.

+
+ <%= link_to new_admin_gateway_path, class: "mt-4 sm:mt-0 inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 transition-all" do %> + + Register New Gateway + <% end %> +
+ + +
+ <% if @gateways.any? %> +
+ + + + + + + + + + + + + + + + + <% @gateways.each do |gateway| %> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <% end %> + +
NameDevice IDStatusActivePriorityTodayTotalLast HeartbeatCreatedActions
+ <%= link_to gateway.name, admin_gateway_path(gateway), + class: "text-sm font-semibold text-blue-600 hover:text-blue-500 transition-colors" %> + + <%= gateway.device_id %> + + <% if gateway.status == "online" %> + + + + + + Online + + <% else %> + + + Offline + + <% end %> + + <% if gateway.active %> + + Active + + <% else %> + + Inactive + + <% end %> + + <%= gateway.priority %> + +
+ + + <%= gateway.messages_sent_today %> + + | + + + <%= gateway.messages_received_today %> + +
+
+
+ + + <%= gateway.total_messages_sent %> + + | + + + <%= gateway.total_messages_received %> + +
+
+ <% if gateway.last_heartbeat_at %> +
+ <%= time_ago_in_words(gateway.last_heartbeat_at) %> ago + <%= gateway.last_heartbeat_at.strftime("%m/%d/%y %H:%M") %> +
+ <% else %> + Never + <% end %> +
+ <%= gateway.created_at.strftime("%m/%d/%y") %> + +
+ <%= link_to test_admin_gateway_path(gateway), + class: "inline-flex items-center gap-1 rounded-lg bg-blue-600 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200" do %> + + Test + <% end %> + <%= button_to toggle_admin_gateway_path(gateway), method: :post, + class: gateway.active ? + "inline-flex items-center gap-2 rounded-lg bg-red-600 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-red-500 transition-all duration-200" : + "inline-flex items-center gap-2 rounded-lg bg-green-600 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-green-500 transition-all duration-200" do %> + <% if gateway.active %> + + Deactivate + <% else %> + + Activate + <% end %> + <% end %> +
+
+
+ <% else %> + +
+
+ +
+

No gateways registered yet

+

Gateway devices will appear here once they connect via the API.

+
+ <% end %> +
+
diff --git a/app/views/admin/gateways/new.html.erb b/app/views/admin/gateways/new.html.erb new file mode 100644 index 0000000..a645cf5 --- /dev/null +++ b/app/views/admin/gateways/new.html.erb @@ -0,0 +1,87 @@ +
+ +
+ <%= link_to admin_gateways_path, class: "inline-flex items-center gap-2 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors" do %> + + Back to Gateways + <% end %> +
+ +
+

Register New Gateway

+

Add a new Android gateway device to your SMS system.

+
+ + +
+ <%= form_with url: admin_gateways_path, method: :post, local: true, class: "space-y-6" do |f| %> + +
+ <%= label_tag "gateway[device_id]", "Device ID", class: "block text-sm font-medium text-gray-700" %> +
+
+ +
+ <%= text_field_tag "gateway[device_id]", nil, + class: "block w-full pl-10 rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm py-3", + placeholder: "device-001", + required: true %> +
+

A unique identifier for this gateway device (e.g., phone model, serial number)

+
+ + +
+ <%= label_tag "gateway[name]", "Gateway Name", class: "block text-sm font-medium text-gray-700" %> +
+
+ +
+ <%= text_field_tag "gateway[name]", nil, + class: "block w-full pl-10 rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm py-3", + placeholder: "Office Phone", + required: true %> +
+

A friendly name to identify this gateway

+
+ + +
+ <%= label_tag "gateway[priority]", "Priority", class: "block text-sm font-medium text-gray-700" %> +
+
+ +
+ <%= number_field_tag "gateway[priority]", 1, + min: 1, + max: 10, + class: "block w-full pl-10 rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm py-3" %> +
+

Priority level (1-10). Higher priority gateways are used first for sending messages.

+
+ + +
+
+
+ +
+
+

Gateway API Key

+

+ After creating the gateway, you'll receive a unique API key. You'll need to configure this key in your Android gateway app to connect it to the system. +

+
+
+
+ + +
+ <%= submit_tag "Register Gateway", + class: "inline-flex justify-center items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 transition-all duration-200" %> + <%= link_to "Cancel", admin_gateways_path, + class: "inline-flex justify-center items-center gap-2 rounded-lg bg-gray-100 px-6 py-3 text-sm font-semibold text-gray-700 hover:bg-gray-200 transition-all duration-200" %> +
+ <% end %> +
+
diff --git a/app/views/admin/gateways/show.html.erb b/app/views/admin/gateways/show.html.erb new file mode 100644 index 0000000..392c287 --- /dev/null +++ b/app/views/admin/gateways/show.html.erb @@ -0,0 +1,565 @@ +
+ +
+ <%= link_to admin_gateways_path, class: "inline-flex items-center gap-2 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors" do %> + + Back to Gateways + <% end %> +
+ + <% if @is_new && @raw_key.present? %> + +
+

Gateway Created Successfully!

+

Your new gateway has been registered and is ready to connect.

+
+ + +
+
+
+ +
+
+

Important: Save this API key now!

+

+ This is the only time you'll be able to see the full API key. You need to configure this key in your Android gateway app to connect it to the system. +

+
+
+
+ + +
+ +
+
+

Scan QR Code

+

Scan this QR code with your Android gateway app to auto-configure

+ +
+
+ <%= @qr_code_data.html_safe %> +
+
+ +
+

+ + QR code contains API key, API base URL, and WebSocket URL +

+
+
+
+ + +
+
+
+

Manual Configuration

+ +
+

Or manually enter these details if QR scanning is unavailable

+
+ +
+ +
+ +
+
+ <%= request.base_url %> +
+ +
+
+ + +
+ +
+
+ <%= request.base_url.sub(/^http/, 'ws') %>/cable +
+ +
+
+ + +
+ +
+
+ <%= @raw_key %> +
+ +
+
+
+
+
+ + +
+

Gateway Details

+ +
+
+
Device ID
+
+ <%= @gateway.device_id %> +
+
+ +
+
Name
+
<%= @gateway.name %>
+
+ +
+
Priority
+
+ + <%= @gateway.priority %> + +
+
+ +
+
Status
+
+ + + Offline (Waiting for connection) + +
+
+ +
+
Created
+
+ <%= @gateway.created_at.strftime("%B %d, %Y at %l:%M %p") %> +
+
+
+
+ + +
+

Quick Setup Guide

+ +
+

Option 1: QR Code (Recommended)

+
    +
  1. Install the Android SMS Gateway app on your device
  2. +
  3. Open the app and look for "Scan QR Code" option
  4. +
  5. Scan the QR code above - configuration will be applied automatically
  6. +
  7. Start the gateway service in the app
  8. +
+
+ +
+

Option 2: Manual Entry

+
    +
  1. Install the Android SMS Gateway app on your device
  2. +
  3. Open the app and navigate to Settings
  4. +
  5. Copy and paste each field from the "Manual Configuration" section above
  6. +
  7. Save the configuration and start the gateway service
  8. +
+
+ +
+

+ + The gateway will appear as "Online" once it successfully connects to the server. +

+
+
+ + + + <% else %> + + +
+
+
+

<%= @gateway.name %>

+

Gateway device details and statistics

+
+ +
+ <% if @gateway.status == "online" %> + + + + + + Online + + <% else %> + + + Offline + + <% end %> +
+
+
+ + +
+ +
+
+
+ +
+

Connection Status

+
+
+

<%= @gateway.status.titleize %>

+
+
+ + +
+
+
+ +
+

Active Status

+
+
+

<%= @gateway.active ? 'Active' : 'Inactive' %>

+
+
+ + +
+
+
+ +
+

Priority Level

+
+
+

<%= @gateway.priority %>

+
+
+ + +
+
+
+ +
+

Messages Sent Today

+
+
+

<%= @gateway.messages_sent_today %>

+
+
+ + +
+
+
+ +
+

Messages Received Today

+
+
+

<%= @gateway.messages_received_today %>

+
+
+ + +
+
+
+ +
+

Total Messages

+
+
+

<%= @gateway.total_messages_sent + @gateway.total_messages_received %>

+
+
+
+ + +
+

Gateway Details

+ +
+
+
Device ID
+
+ <%= @gateway.device_id %> +
+
+ +
+
Name
+
<%= @gateway.name %>
+
+ +
+
Status
+
+ <% if @gateway.status == "online" %> + + + Online + + <% else %> + + + Offline + + <% end %> +
+
+ +
+
Active
+
+ <% if @gateway.active %> + + Active + + <% else %> + + Inactive + + <% end %> +
+
+ +
+
Priority
+
+ + <%= @gateway.priority %> + +
+
+ +
+
Last Heartbeat
+
+ <% if @gateway.last_heartbeat_at %> +
+ + <%= @gateway.last_heartbeat_at.strftime("%B %d, %Y at %l:%M:%S %p") %> + (<%= time_ago_in_words(@gateway.last_heartbeat_at) %> ago) +
+ <% else %> + Never + <% end %> +
+
+ +
+
Total Messages Sent
+
+ + + <%= @gateway.total_messages_sent %> + +
+
+ +
+
Total Messages Received
+
+ + + <%= @gateway.total_messages_received %> + +
+
+ +
+
Created
+
+ <%= @gateway.created_at.strftime("%B %d, %Y at %l:%M %p") %> +
+
+ +
+
Last Updated
+
+ <%= @gateway.updated_at.strftime("%B %d, %Y at %l:%M %p") %> +
+
+
+ + + <% if @gateway.metadata.present? %> +
+

Device Metadata

+
+
<%= JSON.pretty_generate(@gateway.metadata) %>
+
+
+ <% end %> + + +
+
+ <%= link_to test_admin_gateway_path(@gateway), + class: "inline-flex items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200" do %> + + Test Gateway + <% end %> + <%= button_to toggle_admin_gateway_path(@gateway), method: :post, + class: @gateway.active ? + "inline-flex items-center gap-2 rounded-lg bg-red-600 px-6 py-3 text-sm font-semibold text-white shadow-sm hover:bg-red-500 transition-all duration-200" : + "inline-flex items-center gap-2 rounded-lg bg-green-600 px-6 py-3 text-sm font-semibold text-white shadow-sm hover:bg-green-500 transition-all duration-200" do %> + <% if @gateway.active %> + + Deactivate Gateway + <% else %> + + Activate Gateway + <% end %> + <% end %> +
+
+
+ + +
+
+

Recent Messages

+

Last <%= @recent_messages.size %> messages from this gateway

+
+ + <% if @recent_messages.any? %> +
+ + + + + + + + + + + + <% @recent_messages.each do |msg| %> + + + + + + + + <% end %> + +
Message IDPhone NumberDirectionStatusCreated
+ <%= msg.message_id[0..15] %>... + + <%= msg.phone_number %> + + <% if msg.direction == "outbound" %> + + Outbound + + <% else %> + + Inbound + + <% end %> + + <% case msg.status %> + <% when "delivered" %> + Delivered + <% when "sent" %> + Sent + <% when "failed" %> + Failed + <% when "pending" %> + Pending + <% else %> + <%= msg.status.titleize %> + <% end %> + + <%= msg.created_at.strftime("%m/%d/%y %H:%M") %> +
+
+ <% else %> +
+ +

No messages yet

+

Messages will appear here once this gateway starts processing SMS.

+
+ <% end %> +
+ <% end %> +
diff --git a/app/views/admin/gateways/test.html.erb b/app/views/admin/gateways/test.html.erb new file mode 100644 index 0000000..e27aa62 --- /dev/null +++ b/app/views/admin/gateways/test.html.erb @@ -0,0 +1,353 @@ +<% if @gateway.nil? %> +
+

Error: Gateway not found

+ <%= link_to "Back to Gateways", admin_gateways_path, class: "text-red-600 underline" %> +
+<% else %> +
+ +
+ <%= link_to admin_gateway_path(@gateway), class: "inline-flex items-center gap-2 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors" do %> + + Back to Gateway Details + <% end %> +
+ + +
+

Test Gateway: <%= @gateway.name %>

+

Test connection and send test SMS messages to verify gateway functionality.

+
+ + +
+
+

Gateway Status

+ +
+ +
+ +
+
+
+
+
+ + +
+

Connection Information

+ +
+
+
Device ID
+
+ <%= @gateway.device_id %> +
+
+ +
+
Gateway Name
+
<%= @gateway.name %>
+
+ +
+
Priority
+
+ + <%= @gateway.priority %> + +
+
+ +
+
Active
+
+ <% if @gateway.active %> + + Active + + <% else %> + + Inactive + + <% end %> +
+
+
+
+ + +
+

Send Test SMS

+

Send a test SMS message through this gateway to verify it's working correctly.

+ +
+ +
+ +
+
+ +
+ +
+

Enter phone number with country code (e.g., +959123456789)

+
+ + +
+ +
+ +
+

160 characters remaining

+
+ + + + + +
+ + +
+
+
+ + +
+
+
+ +
+
+

Important Notes

+
    +
  • Test SMS messages will be sent to real phone numbers
  • +
  • Ensure the gateway is online and connected before testing
  • +
  • Standard SMS charges may apply to the recipient
  • +
  • Test messages are marked with metadata for identification
  • +
+
+
+
+
+ + +<% end %> diff --git a/app/views/admin/logs/index.html.erb b/app/views/admin/logs/index.html.erb new file mode 100644 index 0000000..6ec0550 --- /dev/null +++ b/app/views/admin/logs/index.html.erb @@ -0,0 +1,235 @@ +
+ +
+

SMS Logs

+

View and filter all SMS messages across your gateways.

+
+ + +
+
+

Filters

+ +
+ + <%= form_with url: admin_logs_path, method: :get, local: true do |f| %> +
+ +
+ <%= label_tag :direction, "Direction", class: "block text-sm font-medium text-gray-700 mb-1" %> + <%= select_tag :direction, + options_for_select([["All Directions", ""], ["Inbound", "inbound"], ["Outbound", "outbound"]], params[:direction]), + class: "block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> +
+ + +
+ <%= label_tag :status, "Status", class: "block text-sm font-medium text-gray-700 mb-1" %> + <%= select_tag :status, + options_for_select([["All Statuses", ""], ["Pending", "pending"], ["Queued", "queued"], ["Sent", "sent"], ["Delivered", "delivered"], ["Failed", "failed"]], params[:status]), + class: "block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> +
+ + +
+ <%= label_tag :phone_number, "Phone Number", class: "block text-sm font-medium text-gray-700 mb-1" %> +
+
+ +
+ <%= text_field_tag :phone_number, params[:phone_number], + class: "block w-full pl-10 rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", + placeholder: "Search phone..." %> +
+
+ + +
+ <%= label_tag :gateway_id, "Gateway", class: "block text-sm font-medium text-gray-700 mb-1" %> + <%= select_tag :gateway_id, + options_for_select([["All Gateways", ""]] + Gateway.order(:name).pluck(:name, :id), params[:gateway_id]), + class: "block w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> +
+ + +
+ <%= label_tag :start_date, "Start Date", class: "block text-sm font-medium text-gray-700 mb-1" %> +
+
+ +
+ <%= date_field_tag :start_date, params[:start_date], + class: "block w-full pl-10 rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> +
+
+ + +
+ <%= label_tag :end_date, "End Date", class: "block text-sm font-medium text-gray-700 mb-1" %> +
+
+ +
+ <%= date_field_tag :end_date, params[:end_date], + class: "block w-full pl-10 rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> +
+
+
+ + +
+ <%= submit_tag "Apply Filters", + class: "inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200" do %> + + Apply Filters + <% end %> + <%= link_to admin_logs_path, + class: "inline-flex items-center gap-2 rounded-lg bg-gray-100 px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-200 transition-all duration-200" do %> + + Clear Filters + <% end %> +
+ <% end %> +
+ + +
+ <% if @messages.any? %> + +
+

+ Showing <%= @messages.size %> of <%= @pagy.count %> messages +

+
+ + +
+ + + + + + + + + + + + + + + + <% @messages.each do |msg| %> + + + + + + + + + + + + <% if msg.error_message.present? %> + + + + <% end %> + <% end %> + +
Message IDPhone NumberMessageDirectionStatusGatewayRetriesCreatedProcessed
+ <%= msg.message_id[0..15] %>... + + <%= msg.phone_number %> + +
<%= msg.message_body %>
+
+ <% if msg.direction == "outbound" %> + + Outbound + + <% else %> + + Inbound + + <% end %> + + <% case msg.status %> + <% when "delivered" %> + Delivered + <% when "sent" %> + Sent + <% when "failed" %> + + Failed + + <% when "pending" %> + Pending + <% when "queued" %> + Queued + <% else %> + <%= msg.status.titleize %> + <% end %> + + <%= msg.gateway&.name || "-" %> + + <% if msg.retry_count > 0 %> + + <%= msg.retry_count %> + + <% else %> + - + <% end %> + + <%= msg.created_at.strftime("%m/%d/%y %H:%M") %> + + <% if msg.delivered_at %> + <%= msg.delivered_at.strftime("%m/%d/%y %H:%M") %> + <% elsif msg.sent_at %> + <%= msg.sent_at.strftime("%m/%d/%y %H:%M") %> + <% elsif msg.failed_at %> + <%= msg.failed_at.strftime("%m/%d/%y %H:%M") %> + <% else %> + - + <% end %> +
+
+ + + <% if @pagy.pages > 1 %> +
+
+ <%== pagy_nav(@pagy) %> +
+
+ <% end %> + <% else %> + +
+ +

No messages found

+

Try adjusting your filters to see more results.

+
+ <% end %> +
+
+ + diff --git a/app/views/admin/sessions/new.html.erb b/app/views/admin/sessions/new.html.erb new file mode 100644 index 0000000..fb3abb8 --- /dev/null +++ b/app/views/admin/sessions/new.html.erb @@ -0,0 +1,49 @@ +
+
+
+ +
+

MySMSAPio Admin

+

Sign in to your admin account

+
+ +
+ <%= form_with url: admin_login_path, method: :post, local: true, class: "space-y-6" do |f| %> +
+ <%= label_tag :email, "Email address", class: "block text-sm font-medium text-gray-700" %> +
+
+ +
+ <%= email_field_tag :email, params[:email], + class: "block w-full pl-10 rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm py-3", + placeholder: "admin@example.com", + autofocus: true, + required: true %> +
+
+ +
+ <%= label_tag :password, "Password", class: "block text-sm font-medium text-gray-700" %> +
+
+ +
+ <%= password_field_tag :password, nil, + class: "block w-full pl-10 rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm py-3", + placeholder: "Enter your password", + required: true %> +
+
+ +
+ <%= submit_tag "Sign in", + class: "flex w-full justify-center rounded-lg bg-blue-600 px-4 py-3 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 transition-all duration-200" %> +
+ <% end %> +
+ +
+ Secure Admin Access +
+
diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb new file mode 100644 index 0000000..8561e4b --- /dev/null +++ b/app/views/layouts/admin.html.erb @@ -0,0 +1,124 @@ + + + + + + Admin - MySMSAPio + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %> + <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + + + + <% if logged_in? %> +
+ +
+
+
+

+ + MySMSAPio +

+
+ +
+
+ + +
+
+
+ + <% if flash[:notice] %> +
+
+
+ +
+
+

<%= flash[:notice] %>

+
+
+
+ <% end %> + + <% if flash[:alert] %> +
+
+
+ +
+
+

<%= flash[:alert] %>

+
+
+
+ <% end %> + + <%= yield %> +
+
+
+
+ <% else %> + +
+ <%= yield %> +
+ <% end %> + + diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb new file mode 100644 index 0000000..f4c33ed --- /dev/null +++ b/app/views/layouts/application.html.erb @@ -0,0 +1,30 @@ + + + + <%= content_for(:title) || "My Smsa Pio" %> + + + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= yield :head %> + + <%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %> + <%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %> + + + + + + <%# Includes all stylesheet files in app/assets/stylesheets %> + <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + +
+ <%= yield %> +
+ + diff --git a/app/views/layouts/mailer.html.erb b/app/views/layouts/mailer.html.erb new file mode 100644 index 0000000..3aac900 --- /dev/null +++ b/app/views/layouts/mailer.html.erb @@ -0,0 +1,13 @@ + + + + + + + + + <%= yield %> + + diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb new file mode 100644 index 0000000..37f0bdd --- /dev/null +++ b/app/views/layouts/mailer.text.erb @@ -0,0 +1 @@ +<%= yield %> diff --git a/app/views/pwa/manifest.json.erb b/app/views/pwa/manifest.json.erb new file mode 100644 index 0000000..aca0f92 --- /dev/null +++ b/app/views/pwa/manifest.json.erb @@ -0,0 +1,22 @@ +{ + "name": "MySmsaPio", + "icons": [ + { + "src": "/icon.png", + "type": "image/png", + "sizes": "512x512" + }, + { + "src": "/icon.png", + "type": "image/png", + "sizes": "512x512", + "purpose": "maskable" + } + ], + "start_url": "/", + "display": "standalone", + "scope": "/", + "description": "MySmsaPio.", + "theme_color": "red", + "background_color": "red" +} diff --git a/app/views/pwa/service-worker.js b/app/views/pwa/service-worker.js new file mode 100644 index 0000000..b3a13fb --- /dev/null +++ b/app/views/pwa/service-worker.js @@ -0,0 +1,26 @@ +// Add a service worker for processing Web Push notifications: +// +// self.addEventListener("push", async (event) => { +// const { title, options } = await event.data.json() +// event.waitUntil(self.registration.showNotification(title, options)) +// }) +// +// self.addEventListener("notificationclick", function(event) { +// event.notification.close() +// event.waitUntil( +// clients.matchAll({ type: "window" }).then((clientList) => { +// for (let i = 0; i < clientList.length; i++) { +// let client = clientList[i] +// let clientPath = (new URL(client.url)).pathname +// +// if (clientPath == event.notification.data.path && "focus" in client) { +// return client.focus() +// } +// } +// +// if (clients.openWindow) { +// return clients.openWindow(event.notification.data.path) +// } +// }) +// ) +// }) diff --git a/bin/brakeman b/bin/brakeman new file mode 100755 index 0000000..ace1c9b --- /dev/null +++ b/bin/brakeman @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +ARGV.unshift("--ensure-latest") + +load Gem.bin_path("brakeman", "brakeman") diff --git a/bin/bundle b/bin/bundle new file mode 100755 index 0000000..50da5fd --- /dev/null +++ b/bin/bundle @@ -0,0 +1,109 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'bundle' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +require "rubygems" + +m = Module.new do + module_function + + def invoked_as_script? + File.expand_path($0) == File.expand_path(__FILE__) + end + + def env_var_version + ENV["BUNDLER_VERSION"] + end + + def cli_arg_version + return unless invoked_as_script? # don't want to hijack other binstubs + return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update` + bundler_version = nil + update_index = nil + ARGV.each_with_index do |a, i| + if update_index && update_index.succ == i && a.match?(Gem::Version::ANCHORED_VERSION_PATTERN) + bundler_version = a + end + next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ + bundler_version = $1 + update_index = i + end + bundler_version + end + + def gemfile + gemfile = ENV["BUNDLE_GEMFILE"] + return gemfile if gemfile && !gemfile.empty? + + File.expand_path("../Gemfile", __dir__) + end + + def lockfile + lockfile = + case File.basename(gemfile) + when "gems.rb" then gemfile.sub(/\.rb$/, ".locked") + else "#{gemfile}.lock" + end + File.expand_path(lockfile) + end + + def lockfile_version + return unless File.file?(lockfile) + lockfile_contents = File.read(lockfile) + return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ + Regexp.last_match(1) + end + + def bundler_requirement + @bundler_requirement ||= + env_var_version || + cli_arg_version || + bundler_requirement_for(lockfile_version) + end + + def bundler_requirement_for(version) + return "#{Gem::Requirement.default}.a" unless version + + bundler_gem_version = Gem::Version.new(version) + + bundler_gem_version.approximate_recommendation + end + + def load_bundler! + ENV["BUNDLE_GEMFILE"] ||= gemfile + + activate_bundler + end + + def activate_bundler + gem_error = activation_error_handling do + gem "bundler", bundler_requirement + end + return if gem_error.nil? + require_error = activation_error_handling do + require "bundler/version" + end + return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION)) + warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`" + exit 42 + end + + def activation_error_handling + yield + nil + rescue StandardError, LoadError => e + e + end +end + +m.load_bundler! + +if m.invoked_as_script? + load Gem.bin_path("bundler", "bundle") +end diff --git a/bin/dev b/bin/dev new file mode 100755 index 0000000..ad72c7d --- /dev/null +++ b/bin/dev @@ -0,0 +1,16 @@ +#!/usr/bin/env sh + +if ! gem list foreman -i --silent; then + echo "Installing foreman..." + gem install foreman +fi + +# Default to port 3000 if not specified +export PORT="${PORT:-3000}" + +# Let the debug gem allow remote connections, +# but avoid loading until `debugger` is called +export RUBY_DEBUG_OPEN="true" +export RUBY_DEBUG_LAZY="true" + +exec foreman start -f Procfile.dev "$@" diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint new file mode 100755 index 0000000..57567d6 --- /dev/null +++ b/bin/docker-entrypoint @@ -0,0 +1,14 @@ +#!/bin/bash -e + +# Enable jemalloc for reduced memory usage and latency. +if [ -z "${LD_PRELOAD+x}" ]; then + LD_PRELOAD=$(find /usr/lib -name libjemalloc.so.2 -print -quit) + export LD_PRELOAD +fi + +# If running the rails server then create or migrate existing database +if [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then + ./bin/rails db:prepare +fi + +exec "${@}" diff --git a/bin/importmap b/bin/importmap new file mode 100755 index 0000000..36502ab --- /dev/null +++ b/bin/importmap @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby + +require_relative "../config/application" +require "importmap/commands" diff --git a/bin/jobs b/bin/jobs new file mode 100755 index 0000000..dcf59f3 --- /dev/null +++ b/bin/jobs @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby + +require_relative "../config/environment" +require "solid_queue/cli" + +SolidQueue::Cli.start(ARGV) diff --git a/bin/kamal b/bin/kamal new file mode 100755 index 0000000..cbe59b9 --- /dev/null +++ b/bin/kamal @@ -0,0 +1,27 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'kamal' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +bundle_binstub = File.expand_path("bundle", __dir__) + +if File.file?(bundle_binstub) + if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") + load(bundle_binstub) + else + abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. +Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") + end +end + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("kamal", "kamal") diff --git a/bin/rails b/bin/rails new file mode 100755 index 0000000..efc0377 --- /dev/null +++ b/bin/rails @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/bin/rake b/bin/rake new file mode 100755 index 0000000..4fbf10b --- /dev/null +++ b/bin/rake @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "rake" +Rake.application.run diff --git a/bin/rubocop b/bin/rubocop new file mode 100755 index 0000000..40330c0 --- /dev/null +++ b/bin/rubocop @@ -0,0 +1,8 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +# explicit rubocop config increases performance slightly while avoiding config confusion. +ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) + +load Gem.bin_path("rubocop", "rubocop") diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..be3db3c --- /dev/null +++ b/bin/setup @@ -0,0 +1,34 @@ +#!/usr/bin/env ruby +require "fileutils" + +APP_ROOT = File.expand_path("..", __dir__) + +def system!(*args) + system(*args, exception: true) +end + +FileUtils.chdir APP_ROOT do + # This script is a way to set up or update your development environment automatically. + # This script is idempotent, so that you can run it at any time and get an expectable outcome. + # Add necessary setup steps to this file. + + puts "== Installing dependencies ==" + system("bundle check") || system!("bundle install") + + # puts "\n== Copying sample files ==" + # unless File.exist?("config/database.yml") + # FileUtils.cp "config/database.yml.sample", "config/database.yml" + # end + + puts "\n== Preparing database ==" + system! "bin/rails db:prepare" + + puts "\n== Removing old logs and tempfiles ==" + system! "bin/rails log:clear tmp:clear" + + unless ARGV.include?("--skip-server") + puts "\n== Starting development server ==" + STDOUT.flush # flush the output before exec(2) so that it displays + exec "bin/dev" + end +end diff --git a/bin/thrust b/bin/thrust new file mode 100755 index 0000000..36bde2d --- /dev/null +++ b/bin/thrust @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("thruster", "thrust") diff --git a/config.ru b/config.ru new file mode 100644 index 0000000..4a3c09a --- /dev/null +++ b/config.ru @@ -0,0 +1,6 @@ +# This file is used by Rack-based servers to start the application. + +require_relative "config/environment" + +run Rails.application +Rails.application.load_server diff --git a/config/application.rb b/config/application.rb new file mode 100644 index 0000000..735a14f --- /dev/null +++ b/config/application.rb @@ -0,0 +1,40 @@ +require_relative "boot" + +require "rails/all" + +# Require the gems listed in Gemfile, including any gems +# you've limited to :test, :development, or :production. +Bundler.require(*Rails.groups) + +module MySmsaPio + class Application < Rails::Application + # Initialize configuration defaults for originally generated Rails version. + config.load_defaults 8.0 + + # Please, add to the `ignore` list any other `lib` subdirectories that do + # not contain `.rb` files, or that should not be reloaded or eager loaded. + # Common ones are `templates`, `generators`, or `middleware`, for example. + config.autoload_lib(ignore: %w[assets tasks]) + + # Configuration for the application, engines, and railties goes here. + # + # These settings can be overridden in specific environments using the files + # in config/environments, which are processed later. + # + # config.time_zone = "Central Time (US & Canada)" + # config.eager_load_paths << Rails.root.join("extras") + + # Don't use pure API-only mode since we have an admin interface + # Instead, we'll use ActionController::API for API endpoints + # and ActionController::Base for admin controllers + # config.api_only = true + + # Session configuration will be set in config/initializers/session_store.rb + + # Use Sidekiq for Active Job + config.active_job.queue_adapter = :sidekiq + + # Configure Active Storage (disable if not using) + # config.active_storage.service = :local + end +end diff --git a/config/boot.rb b/config/boot.rb new file mode 100644 index 0000000..988a5dd --- /dev/null +++ b/config/boot.rb @@ -0,0 +1,4 @@ +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "bundler/setup" # Set up gems listed in the Gemfile. +require "bootsnap/setup" # Speed up boot time by caching expensive operations. diff --git a/config/cable.yml b/config/cable.yml new file mode 100644 index 0000000..fe91e80 --- /dev/null +++ b/config/cable.yml @@ -0,0 +1,13 @@ +# Use Redis for Action Cable in all environments for consistency with WebSocket communication +development: + adapter: redis + url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> + channel_prefix: sms_gateway_development + +test: + adapter: test + +production: + adapter: redis + url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> + channel_prefix: sms_gateway_production diff --git a/config/cache.yml b/config/cache.yml new file mode 100644 index 0000000..19d4908 --- /dev/null +++ b/config/cache.yml @@ -0,0 +1,16 @@ +default: &default + store_options: + # Cap age of oldest cache entry to fulfill retention policies + # max_age: <%= 60.days.to_i %> + max_size: <%= 256.megabytes %> + namespace: <%= Rails.env %> + +development: + <<: *default + +test: + <<: *default + +production: + database: cache + <<: *default diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc new file mode 100644 index 0000000..76543c9 --- /dev/null +++ b/config/credentials.yml.enc @@ -0,0 +1 @@ +VYg+Pr3ixci4F/9xLd7/cnRMtKbfqbWkQrhoe8oukU1i93F/+iVVgnHXyAg+JDtAYDvNM4O5QCVPvnGgbi5v0Pk4inrWI4RMkZ6saF7OXew+UdZ0L5EFfTdLQ6ByhjXfKJus1V8QfOjFKHqgA7LF8sr+4249U3lqXqDtQlZlmf06yK0bwkjpEhCdIaxRlDJjKg/l9hmEej6rtMCkKag9hvHZr5LZrTZQm+RwqFfMM8WVBJU9pbUo8R8heqCvDZUSUeMvOYwQ1pH5tRetg8H9DslIygVDycdXoFuw6ZySIR/iOLHFA8J7hSRP64NC2wPrH64fGeGtmuDsj73EFVz//+dQ6cpbiPzsLipxAr8C4LPi4gGCmhCiTRoWF1s+8hvNcmfswkMnSGsWAttEFST6GGjFOMuY5P3mNNl+ZTsAIj3yGQ9iDekonHVfht8BinwFvMoy4qMpkXgu3giL2HZE/inAurkSDjsYowj8JyB7aqMBcAfws+q+XrxV--ui0wGQS7q48MvxMO--IBvNf1XGuNV+40YhWsLTRw== \ No newline at end of file diff --git a/config/database.yml b/config/database.yml new file mode 100644 index 0000000..814e8ff --- /dev/null +++ b/config/database.yml @@ -0,0 +1,98 @@ +# PostgreSQL. Versions 9.3 and up are supported. +# +# Install the pg driver: +# gem install pg +# On macOS with Homebrew: +# gem install pg -- --with-pg-config=/usr/local/bin/pg_config +# On Windows: +# gem install pg +# Choose the win32 build. +# Install PostgreSQL and put its /bin directory on your path. +# +# Configure Using Gemfile +# gem "pg" +# +default: &default + adapter: postgresql + encoding: unicode + # For details on connection pooling, see Rails configuration guide + # https://guides.rubyonrails.org/configuring.html#database-pooling + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + + +development: + <<: *default + database: my_smsa_pio_development + + # The specified database role being used to connect to PostgreSQL. + # To create additional roles in PostgreSQL see `$ createuser --help`. + # When left blank, PostgreSQL will use the default role. This is + # the same name as the operating system user running Rails. + #username: my_smsa_pio + + # The password associated with the PostgreSQL role (username). + #password: + + # Connect on a TCP socket. Omitted by default since the client uses a + # domain socket that doesn't need configuration. Windows does not have + # domain sockets, so uncomment these lines. + #host: localhost + + # The TCP port the server listens on. Defaults to 5432. + # If your server runs on a different port number, change accordingly. + #port: 5432 + + # Schema search path. The server defaults to $user,public + #schema_search_path: myapp,sharedapp,public + + # Minimum log levels, in increasing order: + # debug5, debug4, debug3, debug2, debug1, + # log, notice, warning, error, fatal, and panic + # Defaults to warning. + #min_messages: notice + +# Warning: The database defined as "test" will be erased and +# re-generated from your development database when you run "rake". +# Do not set this db to the same as development or production. +test: + <<: *default + database: my_smsa_pio_test + +# As with config/credentials.yml, you never want to store sensitive information, +# like your database password, in your source code. If your source code is +# ever seen by anyone, they now have access to your database. +# +# Instead, provide the password or a full connection URL as an environment +# variable when you boot the app. For example: +# +# DATABASE_URL="postgres://myuser:mypass@localhost/somedatabase" +# +# If the connection URL is provided in the special DATABASE_URL environment +# variable, Rails will automatically merge its configuration values on top of +# the values provided in this file. Alternatively, you can specify a connection +# URL environment variable explicitly: +# +# production: +# url: <%= ENV["MY_APP_DATABASE_URL"] %> +# +# Read https://guides.rubyonrails.org/configuring.html#configuring-a-database +# for a full overview on how database connection configuration can be specified. +# +production: + primary: &primary_production + <<: *default + database: my_smsa_pio_production + username: my_smsa_pio + password: <%= ENV["MY_SMSA_PIO_DATABASE_PASSWORD"] %> + cache: + <<: *primary_production + database: my_smsa_pio_production_cache + migrations_paths: db/cache_migrate + queue: + <<: *primary_production + database: my_smsa_pio_production_queue + migrations_paths: db/queue_migrate + cable: + <<: *primary_production + database: my_smsa_pio_production_cable + migrations_paths: db/cable_migrate diff --git a/config/deploy.yml b/config/deploy.yml new file mode 100644 index 0000000..af86a72 --- /dev/null +++ b/config/deploy.yml @@ -0,0 +1,116 @@ +# Name of your application. Used to uniquely configure containers. +service: my_smsa_pio + +# Name of the container image. +image: your-user/my_smsa_pio + +# Deploy to these servers. +servers: + web: + - 192.168.0.1 + # job: + # hosts: + # - 192.168.0.1 + # cmd: bin/jobs + +# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server. +# Remove this section when using multiple web servers and ensure you terminate SSL at your load balancer. +# +# Note: If using Cloudflare, set encryption mode in SSL/TLS setting to "Full" to enable CF-to-app encryption. +proxy: + ssl: true + host: app.example.com + +# Credentials for your image host. +registry: + # Specify the registry server, if you're not using Docker Hub + # server: registry.digitalocean.com / ghcr.io / ... + username: your-user + + # Always use an access token rather than real password when possible. + password: + - KAMAL_REGISTRY_PASSWORD + +# Inject ENV variables into containers (secrets come from .kamal/secrets). +env: + secret: + - RAILS_MASTER_KEY + clear: + # Run the Solid Queue Supervisor inside the web server's Puma process to do jobs. + # When you start using multiple servers, you should split out job processing to a dedicated machine. + SOLID_QUEUE_IN_PUMA: true + + # Set number of processes dedicated to Solid Queue (default: 1) + # JOB_CONCURRENCY: 3 + + # Set number of cores available to the application on each server (default: 1). + # WEB_CONCURRENCY: 2 + + # Match this to any external database server to configure Active Record correctly + # Use my_smsa_pio-db for a db accessory server on same machine via local kamal docker network. + # DB_HOST: 192.168.0.2 + + # Log everything from Rails + # RAILS_LOG_LEVEL: debug + +# Aliases are triggered with "bin/kamal ". You can overwrite arguments on invocation: +# "bin/kamal logs -r job" will tail logs from the first server in the job section. +aliases: + console: app exec --interactive --reuse "bin/rails console" + shell: app exec --interactive --reuse "bash" + logs: app logs -f + dbc: app exec --interactive --reuse "bin/rails dbconsole" + + +# Use a persistent storage volume for sqlite database files and local Active Storage files. +# Recommended to change this to a mounted volume path that is backed up off server. +volumes: + - "my_smsa_pio_storage:/rails/storage" + + +# Bridge fingerprinted assets, like JS and CSS, between versions to avoid +# hitting 404 on in-flight requests. Combines all files from new and old +# version inside the asset_path. +asset_path: /rails/public/assets + +# Configure the image builder. +builder: + arch: amd64 + + # # Build image via remote server (useful for faster amd64 builds on arm64 computers) + # remote: ssh://docker@docker-builder-server + # + # # Pass arguments and secrets to the Docker build process + # args: + # RUBY_VERSION: ruby-3.4.7 + # secrets: + # - GITHUB_TOKEN + # - RAILS_MASTER_KEY + +# Use a different ssh user than root +# ssh: +# user: app + +# Use accessory services (secrets come from .kamal/secrets). +# accessories: +# db: +# image: mysql:8.0 +# host: 192.168.0.2 +# # Change to 3306 to expose port to the world instead of just local network. +# port: "127.0.0.1:3306:3306" +# env: +# clear: +# MYSQL_ROOT_HOST: '%' +# secret: +# - MYSQL_ROOT_PASSWORD +# files: +# - config/mysql/production.cnf:/etc/mysql/my.cnf +# - db/production.sql:/docker-entrypoint-initdb.d/setup.sql +# directories: +# - data:/var/lib/mysql +# redis: +# image: redis:7.0 +# host: 192.168.0.2 +# port: 6379 +# directories: +# - data:/data diff --git a/config/environment.rb b/config/environment.rb new file mode 100644 index 0000000..cac5315 --- /dev/null +++ b/config/environment.rb @@ -0,0 +1,5 @@ +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/config/environments/development.rb b/config/environments/development.rb new file mode 100644 index 0000000..0103ba3 --- /dev/null +++ b/config/environments/development.rb @@ -0,0 +1,81 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Make code changes take effect immediately without server restart. + config.enable_reloading = true + + # Do not eager load code on boot. + config.eager_load = false + + # Show full error reports. + config.consider_all_requests_local = true + + # Enable server timing. + config.server_timing = true + + # Enable/disable Action Controller caching. By default Action Controller caching is disabled. + # Run rails dev:cache to toggle Action Controller caching. + if Rails.root.join("tmp/caching-dev.txt").exist? + config.action_controller.perform_caching = true + config.action_controller.enable_fragment_cache_logging = true + config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" } + else + config.action_controller.perform_caching = false + end + + # Change to :null_store to avoid any caching. + config.cache_store = :memory_store + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Don't care if the mailer can't send. + config.action_mailer.raise_delivery_errors = false + + # Make template changes take effect immediately. + config.action_mailer.perform_caching = false + + # Set localhost to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "localhost", port: 3000 } + + # Print deprecation notices to the Rails logger. + config.active_support.deprecation = :log + + # Raise an error on page load if there are pending migrations. + config.active_record.migration_error = :page_load + + # Highlight code that triggered database queries in logs. + config.active_record.verbose_query_logs = true + + # Append comments with runtime information tags to SQL queries in logs. + config.active_record.query_log_tags_enabled = true + + # Highlight code that enqueued background job in logs. + config.active_job.verbose_enqueue_logs = true + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + config.action_view.annotate_rendered_view_with_filenames = true + + # Allow Action Cable connections from any origin in development + config.action_cable.disable_request_forgery_protection = true + + # Allow WebSocket connections from local network + config.action_cable.allowed_request_origins = [ + /http:\/\/localhost.*/, + /http:\/\/127\.0\.0\.1.*/, + /http:\/\/192\.168\..*/, # Local network + /http:\/\/10\..*/, # Local network + /http:\/\/172\.(1[6-9]|2[0-9]|3[0-1])\..*/ # Local network + ] + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true + + # Apply autocorrection by RuboCop to files generated by `bin/rails generate`. + # config.generators.apply_rubocop_autocorrect_after_generate! +end diff --git a/config/environments/production.rb b/config/environments/production.rb new file mode 100644 index 0000000..bdcd01d --- /dev/null +++ b/config/environments/production.rb @@ -0,0 +1,90 @@ +require "active_support/core_ext/integer/time" + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Code is not reloaded between requests. + config.enable_reloading = false + + # Eager load code on boot for better performance and memory savings (ignored by Rake tasks). + config.eager_load = true + + # Full error reports are disabled. + config.consider_all_requests_local = false + + # Turn on fragment caching in view templates. + config.action_controller.perform_caching = true + + # Cache assets for far-future expiry since they are all digest stamped. + config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } + + # Enable serving of images, stylesheets, and JavaScripts from an asset server. + # config.asset_host = "http://assets.example.com" + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Assume all access to the app is happening through a SSL-terminating reverse proxy. + config.assume_ssl = true + + # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. + config.force_ssl = true + + # Skip http-to-https redirect for the default health check endpoint. + # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } + + # Log to STDOUT with the current request id as a default log tag. + config.log_tags = [ :request_id ] + config.logger = ActiveSupport::TaggedLogging.logger(STDOUT) + + # Change to "debug" to log everything (including potentially personally-identifiable information!) + config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") + + # Prevent health checks from clogging up the logs. + config.silence_healthcheck_path = "/up" + + # Don't log any deprecations. + config.active_support.report_deprecations = false + + # Replace the default in-process memory cache store with a durable alternative. + config.cache_store = :solid_cache_store + + # Replace the default in-process and non-durable queuing backend for Active Job. + config.active_job.queue_adapter = :solid_queue + config.solid_queue.connects_to = { database: { writing: :queue } } + + # Ignore bad email addresses and do not raise email delivery errors. + # Set this to true and configure the email server for immediate delivery to raise delivery errors. + # config.action_mailer.raise_delivery_errors = false + + # Set host to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "example.com" } + + # Specify outgoing SMTP server. Remember to add smtp/* credentials via rails credentials:edit. + # config.action_mailer.smtp_settings = { + # user_name: Rails.application.credentials.dig(:smtp, :user_name), + # password: Rails.application.credentials.dig(:smtp, :password), + # address: "smtp.example.com", + # port: 587, + # authentication: :plain + # } + + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to + # the I18n.default_locale when a translation cannot be found). + config.i18n.fallbacks = true + + # Do not dump schema after migrations. + config.active_record.dump_schema_after_migration = false + + # Only use :id for inspections in production. + config.active_record.attributes_for_inspect = [ :id ] + + # Enable DNS rebinding protection and other `Host` header attacks. + # config.hosts = [ + # "example.com", # Allow requests from example.com + # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` + # ] + # + # Skip DNS rebinding protection for the default health check endpoint. + # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } +end diff --git a/config/environments/test.rb b/config/environments/test.rb new file mode 100644 index 0000000..c2095b1 --- /dev/null +++ b/config/environments/test.rb @@ -0,0 +1,53 @@ +# The test environment is used exclusively to run your application's +# test suite. You never need to work with it otherwise. Remember that +# your test database is "scratch space" for the test suite and is wiped +# and recreated between test runs. Don't rely on the data there! + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # While tests run files are not watched, reloading is not necessary. + config.enable_reloading = false + + # Eager loading loads your entire application. When running a single test locally, + # this is usually not necessary, and can slow down your test suite. However, it's + # recommended that you enable it in continuous integration systems to ensure eager + # loading is working properly before deploying your code. + config.eager_load = ENV["CI"].present? + + # Configure public file server for tests with cache-control for performance. + config.public_file_server.headers = { "cache-control" => "public, max-age=3600" } + + # Show full error reports. + config.consider_all_requests_local = true + config.cache_store = :null_store + + # Render exception templates for rescuable exceptions and raise for other exceptions. + config.action_dispatch.show_exceptions = :rescuable + + # Disable request forgery protection in test environment. + config.action_controller.allow_forgery_protection = false + + # Store uploaded files on the local file system in a temporary directory. + config.active_storage.service = :test + + # Tell Action Mailer not to deliver emails to the real world. + # The :test delivery method accumulates sent emails in the + # ActionMailer::Base.deliveries array. + config.action_mailer.delivery_method = :test + + # Set host to be used by links generated in mailer templates. + config.action_mailer.default_url_options = { host: "example.com" } + + # Print deprecation notices to the stderr. + config.active_support.deprecation = :stderr + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Raise error when a before_action's only/except options reference missing actions. + config.action_controller.raise_on_missing_callback_actions = true +end diff --git a/config/importmap.rb b/config/importmap.rb new file mode 100644 index 0000000..909dfc5 --- /dev/null +++ b/config/importmap.rb @@ -0,0 +1,7 @@ +# Pin npm packages by running ./bin/importmap + +pin "application" +pin "@hotwired/turbo-rails", to: "turbo.min.js" +pin "@hotwired/stimulus", to: "stimulus.min.js" +pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" +pin_all_from "app/javascript/controllers", under: "controllers" diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb new file mode 100644 index 0000000..4873244 --- /dev/null +++ b/config/initializers/assets.rb @@ -0,0 +1,7 @@ +# Be sure to restart your server when you modify this file. + +# Version of your assets, change this if you want to expire all your assets. +Rails.application.config.assets.version = "1.0" + +# Add additional assets to the asset load path. +# Rails.application.config.assets.paths << Emoji.images_path diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb new file mode 100644 index 0000000..b3076b3 --- /dev/null +++ b/config/initializers/content_security_policy.rb @@ -0,0 +1,25 @@ +# Be sure to restart your server when you modify this file. + +# Define an application-wide content security policy. +# See the Securing Rails Applications Guide for more information: +# https://guides.rubyonrails.org/security.html#content-security-policy-header + +# Rails.application.configure do +# config.content_security_policy do |policy| +# policy.default_src :self, :https +# policy.font_src :self, :https, :data +# policy.img_src :self, :https, :data +# policy.object_src :none +# policy.script_src :self, :https +# policy.style_src :self, :https +# # Specify URI for violation reports +# # policy.report_uri "/csp-violation-report-endpoint" +# end +# +# # Generate session nonces for permitted importmap, inline scripts, and inline styles. +# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } +# config.content_security_policy_nonce_directives = %w(script-src style-src) +# +# # Report violations without enforcing the policy. +# # config.content_security_policy_report_only = true +# end diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb new file mode 100644 index 0000000..dc9832f --- /dev/null +++ b/config/initializers/cors.rb @@ -0,0 +1,17 @@ +# Be sure to restart your server when you modify this file. + +# Avoid CORS issues when API is called from the frontend app. +# Handle Cross-Origin Resource Sharing (CORS) in order to accept cross-origin AJAX requests. + +# Read more: https://github.com/cyu/rack-cors + +Rails.application.config.middleware.insert_before 0, Rack::Cors do + allow do + origins ENV.fetch("ALLOWED_ORIGINS", "*").split(",") + + resource "*", + headers: :any, + methods: [:get, :post, :put, :patch, :delete, :options, :head], + expose: ["Authorization"] + end +end diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb new file mode 100644 index 0000000..c0b717f --- /dev/null +++ b/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,8 @@ +# Be sure to restart your server when you modify this file. + +# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. +# Use this to limit dissemination of sensitive information. +# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. +Rails.application.config.filter_parameters += [ + :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc +] diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb new file mode 100644 index 0000000..3860f65 --- /dev/null +++ b/config/initializers/inflections.rb @@ -0,0 +1,16 @@ +# Be sure to restart your server when you modify this file. + +# Add new inflection rules using the following format. Inflections +# are locale specific, and you may define rules for as many different +# locales as you wish. All of these examples are active by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.plural /^(ox)$/i, "\\1en" +# inflect.singular /^(ox)en/i, "\\1" +# inflect.irregular "person", "people" +# inflect.uncountable %w( fish sheep ) +# end + +# These inflection rules are supported but not enabled by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.acronym "RESTful" +# end diff --git a/config/initializers/pagy.rb b/config/initializers/pagy.rb new file mode 100644 index 0000000..228986b --- /dev/null +++ b/config/initializers/pagy.rb @@ -0,0 +1,33 @@ +# Pagy initializer file (6.x) +# Customize only what you really need and notice that the core Pagy works also without any of the following lines. +# Should you just cherry pick part of this file, please maintain the require-order of the extras. + +# Extras +# See https://ddnexus.github.io/pagy/extras + +# Backend Extras + +# Array extra: Paginate arrays +# See https://ddnexus.github.io/pagy/extras/array +# require 'pagy/extras/array' + +# Countless extra: Paginate without any count +# See https://ddnexus.github.io/pagy/extras/countless +# require 'pagy/extras/countless' + +# Metadata extra: Provides the pagination metadata to Javascript frameworks +# See https://ddnexus.github.io/pagy/extras/metadata +require "pagy/extras/metadata" + +# Items extra: Allow the client to request a custom number of items per page +# See https://ddnexus.github.io/pagy/extras/items +# require 'pagy/extras/items' + +# Overflow extra: Allow for easy handling of overflowing pages +# See https://ddnexus.github.io/pagy/extras/overflow +require "pagy/extras/overflow" +Pagy::DEFAULT[:overflow] = :last_page + +# Default configuration +Pagy::DEFAULT[:items] = 20 +Pagy::DEFAULT[:max_items] = 100 diff --git a/config/initializers/phonelib.rb b/config/initializers/phonelib.rb new file mode 100644 index 0000000..fd02c12 --- /dev/null +++ b/config/initializers/phonelib.rb @@ -0,0 +1,2 @@ +# Configure Phonelib for phone number validation +Phonelib.default_country = ENV.fetch("DEFAULT_COUNTRY_CODE", "US") diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb new file mode 100644 index 0000000..75ec50f --- /dev/null +++ b/config/initializers/session_store.rb @@ -0,0 +1,3 @@ +# Configure session store for admin interface +# Even though this is an API-only app, the admin interface needs sessions +Rails.application.config.session_store :cookie_store, key: '_my_smsa_pio_session' diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb new file mode 100644 index 0000000..fa66d91 --- /dev/null +++ b/config/initializers/sidekiq.rb @@ -0,0 +1,19 @@ +require "sidekiq" +require "sidekiq-cron" + +# Sidekiq configuration +Sidekiq.configure_server do |config| + config.redis = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/1") } + + # Load cron jobs from YAML file if it exists + schedule_file = "config/sidekiq_cron.yml" + + if File.exist?(schedule_file) + schedule = YAML.load_file(schedule_file) + Sidekiq::Cron::Job.load_from_hash(schedule) + end +end + +Sidekiq.configure_client do |config| + config.redis = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/1") } +end diff --git a/config/locales/en.yml b/config/locales/en.yml new file mode 100644 index 0000000..6c349ae --- /dev/null +++ b/config/locales/en.yml @@ -0,0 +1,31 @@ +# Files in the config/locales directory are used for internationalization and +# are automatically loaded by Rails. If you want to use locales other than +# English, add the necessary files in this directory. +# +# To use the locales, use `I18n.t`: +# +# I18n.t "hello" +# +# In views, this is aliased to just `t`: +# +# <%= t("hello") %> +# +# To use a different locale, set it with `I18n.locale`: +# +# I18n.locale = :es +# +# This would use the information in config/locales/es.yml. +# +# To learn more about the API, please read the Rails Internationalization guide +# at https://guides.rubyonrails.org/i18n.html. +# +# Be aware that YAML interprets the following case-insensitive strings as +# booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings +# must be quoted to be interpreted as strings. For example: +# +# en: +# "yes": yup +# enabled: "ON" + +en: + hello: "Hello world" diff --git a/config/puma.rb b/config/puma.rb new file mode 100644 index 0000000..a248513 --- /dev/null +++ b/config/puma.rb @@ -0,0 +1,41 @@ +# This configuration file will be evaluated by Puma. The top-level methods that +# are invoked here are part of Puma's configuration DSL. For more information +# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. +# +# Puma starts a configurable number of processes (workers) and each process +# serves each request in a thread from an internal thread pool. +# +# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You +# should only set this value when you want to run 2 or more workers. The +# default is already 1. +# +# The ideal number of threads per worker depends both on how much time the +# application spends waiting for IO operations and on how much you wish to +# prioritize throughput over latency. +# +# As a rule of thumb, increasing the number of threads will increase how much +# traffic a given process can handle (throughput), but due to CRuby's +# Global VM Lock (GVL) it has diminishing returns and will degrade the +# response time (latency) of the application. +# +# The default is set to 3 threads as it's deemed a decent compromise between +# throughput and latency for the average Rails application. +# +# Any libraries that use a connection pool or another resource pool should +# be configured to provide at least as many connections as the number of +# threads. This includes Active Record's `pool` parameter in `database.yml`. +threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) +threads threads_count, threads_count + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +port ENV.fetch("PORT", 3000) + +# Allow puma to be restarted by `bin/rails restart` command. +plugin :tmp_restart + +# Run the Solid Queue supervisor inside of Puma for single-server deployments +plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"] + +# Specify the PID file. Defaults to tmp/pids/server.pid in development. +# In other environments, only set the PID file if requested. +pidfile ENV["PIDFILE"] if ENV["PIDFILE"] diff --git a/config/queue.yml b/config/queue.yml new file mode 100644 index 0000000..9eace59 --- /dev/null +++ b/config/queue.yml @@ -0,0 +1,18 @@ +default: &default + dispatchers: + - polling_interval: 1 + batch_size: 500 + workers: + - queues: "*" + threads: 3 + processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %> + polling_interval: 0.1 + +development: + <<: *default + +test: + <<: *default + +production: + <<: *default diff --git a/config/recurring.yml b/config/recurring.yml new file mode 100644 index 0000000..b4207f9 --- /dev/null +++ b/config/recurring.yml @@ -0,0 +1,15 @@ +# examples: +# periodic_cleanup: +# class: CleanSoftDeletedRecordsJob +# queue: background +# args: [ 1000, { batch_size: 500 } ] +# schedule: every hour +# periodic_cleanup_with_command: +# command: "SoftDeletedRecord.due.delete_all" +# priority: 2 +# schedule: at 5am every day + +production: + clear_solid_queue_finished_jobs: + command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)" + schedule: every hour at minute 12 diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 0000000..b704774 --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,73 @@ +Rails.application.routes.draw do + # Health check endpoint + get "up" => "rails/health#show", as: :rails_health_check + + # Mount Action Cable WebSocket server + mount ActionCable.server => "/cable" + + # API routes + namespace :api do + namespace :v1 do + # Gateway device endpoints + namespace :gateway do + post "register", to: "registrations#create" + post "heartbeat", to: "heartbeats#create" + + namespace :sms do + post "received", to: "sms#received" + post "status", to: "sms#status" + end + end + + # Client application endpoints + post "sms/send", to: "sms#send_sms" + get "sms/status/:message_id", to: "sms#status" + get "sms/received", to: "sms#received" + + # OTP endpoints + namespace :otp do + post "send", to: "otp#send_otp" + post "verify", to: "otp#verify" + end + + # Admin endpoints + namespace :admin do + get "gateways", to: "gateways#index" + post "gateways/:id/toggle", to: "gateways#toggle" + get "stats", to: "stats#index" + end + end + end + + # Admin interface routes + namespace :admin do + get "login", to: "sessions#new" + post "login", to: "sessions#create" + delete "logout", to: "sessions#destroy" + + get "dashboard", to: "dashboard#index" + root to: "dashboard#index" + + resources :api_keys, only: [:index, :new, :create, :show, :destroy] do + member do + post :toggle + end + end + + resources :logs, only: [:index] + + resources :gateways, only: [:index, :new, :create, :show] do + member do + post :toggle + get :test + post :send_test_sms + post :check_connection + end + end + + get "api_tester", to: "api_tester#index" + end + + # Root route + root to: proc { [200, {}, ["SMS Gateway API v1.0"]] } +end diff --git a/config/sidekiq_cron.yml b/config/sidekiq_cron.yml new file mode 100644 index 0000000..3041a0b --- /dev/null +++ b/config/sidekiq_cron.yml @@ -0,0 +1,20 @@ +reset_daily_counters: + cron: "0 0 * * *" # Daily at midnight + class: "ResetDailyCountersJob" + queue: default + description: "Reset daily message counters for all gateways" + active_job: true + +cleanup_expired_otps: + cron: "*/15 * * * *" # Every 15 minutes + class: "CleanupExpiredOtpsJob" + queue: low_priority + description: "Delete expired OTP codes from database" + active_job: true + +check_gateway_health: + cron: "* * * * *" # Every minute + class: "CheckGatewayHealthJob" + queue: default + description: "Check gateway health and mark offline gateways" + active_job: true diff --git a/config/storage.yml b/config/storage.yml new file mode 100644 index 0000000..4942ab6 --- /dev/null +++ b/config/storage.yml @@ -0,0 +1,34 @@ +test: + service: Disk + root: <%= Rails.root.join("tmp/storage") %> + +local: + service: Disk + root: <%= Rails.root.join("storage") %> + +# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) +# amazon: +# service: S3 +# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> +# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> +# region: us-east-1 +# bucket: your_own_bucket-<%= Rails.env %> + +# Remember not to checkin your GCS keyfile to a repository +# google: +# service: GCS +# project: your_project +# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> +# bucket: your_own_bucket-<%= Rails.env %> + +# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) +# microsoft: +# service: AzureStorage +# storage_account_name: your_account_name +# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> +# container: your_container_name-<%= Rails.env %> + +# mirror: +# service: Mirror +# primary: local +# mirrors: [ amazon, google, microsoft ] diff --git a/db/cable_schema.rb b/db/cable_schema.rb new file mode 100644 index 0000000..2366660 --- /dev/null +++ b/db/cable_schema.rb @@ -0,0 +1,11 @@ +ActiveRecord::Schema[7.1].define(version: 1) do + create_table "solid_cable_messages", force: :cascade do |t| + t.binary "channel", limit: 1024, null: false + t.binary "payload", limit: 536870912, null: false + t.datetime "created_at", null: false + t.integer "channel_hash", limit: 8, null: false + t.index ["channel"], name: "index_solid_cable_messages_on_channel" + t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash" + t.index ["created_at"], name: "index_solid_cable_messages_on_created_at" + end +end diff --git a/db/cache_schema.rb b/db/cache_schema.rb new file mode 100644 index 0000000..6005a29 --- /dev/null +++ b/db/cache_schema.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +ActiveRecord::Schema[7.2].define(version: 1) do + create_table "solid_cache_entries", force: :cascade do |t| + t.binary "key", limit: 1024, null: false + t.binary "value", limit: 536870912, null: false + t.datetime "created_at", null: false + t.integer "key_hash", limit: 8, null: false + t.integer "byte_size", limit: 4, null: false + t.index ["byte_size"], name: "index_solid_cache_entries_on_byte_size" + t.index ["key_hash", "byte_size"], name: "index_solid_cache_entries_on_key_hash_and_byte_size" + t.index ["key_hash"], name: "index_solid_cache_entries_on_key_hash", unique: true + end +end diff --git a/db/migrate/20251019070342_create_gateways.rb b/db/migrate/20251019070342_create_gateways.rb new file mode 100644 index 0000000..151bc8c --- /dev/null +++ b/db/migrate/20251019070342_create_gateways.rb @@ -0,0 +1,24 @@ +class CreateGateways < ActiveRecord::Migration[8.0] + def change + create_table :gateways do |t| + t.string :device_id, null: false + t.string :name + t.string :api_key_digest, null: false + t.string :status, default: "offline" + t.datetime :last_heartbeat_at + t.integer :messages_sent_today, default: 0 + t.integer :messages_received_today, default: 0 + t.integer :total_messages_sent, default: 0 + t.integer :total_messages_received, default: 0 + t.boolean :active, default: true + t.integer :priority, default: 1 + t.jsonb :metadata, default: {} + + t.timestamps + end + + add_index :gateways, :device_id, unique: true + add_index :gateways, :status + add_index :gateways, :active + end +end diff --git a/db/migrate/20251019070519_create_sms_messages.rb b/db/migrate/20251019070519_create_sms_messages.rb new file mode 100644 index 0000000..6de0945 --- /dev/null +++ b/db/migrate/20251019070519_create_sms_messages.rb @@ -0,0 +1,26 @@ +class CreateSmsMessages < ActiveRecord::Migration[8.0] + def change + create_table :sms_messages do |t| + t.references :gateway, foreign_key: true + t.string :message_id, null: false + t.string :direction, null: false + t.string :phone_number, null: false + t.text :message_body, null: false + t.string :status, default: "pending" + t.text :error_message + t.integer :retry_count, default: 0 + t.datetime :sent_at + t.datetime :delivered_at + t.datetime :failed_at + t.jsonb :metadata, default: {} + + t.timestamps + end + + add_index :sms_messages, :message_id, unique: true + add_index :sms_messages, :phone_number + add_index :sms_messages, [:phone_number, :created_at] + add_index :sms_messages, [:status, :created_at] + add_index :sms_messages, :direction + end +end diff --git a/db/migrate/20251019070520_create_otp_codes.rb b/db/migrate/20251019070520_create_otp_codes.rb new file mode 100644 index 0000000..80e461c --- /dev/null +++ b/db/migrate/20251019070520_create_otp_codes.rb @@ -0,0 +1,21 @@ +class CreateOtpCodes < ActiveRecord::Migration[8.0] + def change + create_table :otp_codes do |t| + t.string :phone_number, null: false + t.string :code, null: false + t.string :purpose, default: "authentication" + t.datetime :expires_at, null: false + t.boolean :verified, default: false + t.datetime :verified_at + t.integer :attempts, default: 0 + t.string :ip_address + t.jsonb :metadata, default: {} + + t.timestamps + end + + add_index :otp_codes, :phone_number + add_index :otp_codes, :expires_at + add_index :otp_codes, [:phone_number, :verified, :expires_at] + end +end diff --git a/db/migrate/20251019070521_create_webhook_configs.rb b/db/migrate/20251019070521_create_webhook_configs.rb new file mode 100644 index 0000000..ef61e75 --- /dev/null +++ b/db/migrate/20251019070521_create_webhook_configs.rb @@ -0,0 +1,18 @@ +class CreateWebhookConfigs < ActiveRecord::Migration[8.0] + def change + create_table :webhook_configs do |t| + t.string :name, null: false + t.string :url, null: false + t.string :event_type, null: false + t.string :secret_key + t.boolean :active, default: true + t.integer :timeout, default: 30 + t.integer :retry_count, default: 3 + + t.timestamps + end + + add_index :webhook_configs, :event_type + add_index :webhook_configs, :active + end +end diff --git a/db/migrate/20251019070522_create_api_keys.rb b/db/migrate/20251019070522_create_api_keys.rb new file mode 100644 index 0000000..ee0ae0d --- /dev/null +++ b/db/migrate/20251019070522_create_api_keys.rb @@ -0,0 +1,19 @@ +class CreateApiKeys < ActiveRecord::Migration[8.0] + def change + create_table :api_keys do |t| + t.string :name, null: false + t.string :key_digest, null: false + t.string :key_prefix, null: false + t.boolean :active, default: true + t.datetime :last_used_at + t.datetime :expires_at + t.jsonb :permissions, default: {} + + t.timestamps + end + + add_index :api_keys, :key_digest, unique: true + add_index :api_keys, :key_prefix + add_index :api_keys, :active + end +end diff --git a/db/migrate/20251020025135_create_admins.rb b/db/migrate/20251020025135_create_admins.rb new file mode 100644 index 0000000..2f337d4 --- /dev/null +++ b/db/migrate/20251020025135_create_admins.rb @@ -0,0 +1,14 @@ +class CreateAdmins < ActiveRecord::Migration[8.0] + def change + create_table :admins do |t| + t.string :email, null: false + t.string :password_digest, null: false + t.string :name, null: false + t.datetime :last_login_at + + t.timestamps + end + + add_index :admins, :email, unique: true + end +end diff --git a/db/migrate/20251020031401_rename_admins_to_admin_users.rb b/db/migrate/20251020031401_rename_admins_to_admin_users.rb new file mode 100644 index 0000000..84ed508 --- /dev/null +++ b/db/migrate/20251020031401_rename_admins_to_admin_users.rb @@ -0,0 +1,5 @@ +class RenameAdminsToAdminUsers < ActiveRecord::Migration[8.0] + def change + rename_table :admins, :admin_users + end +end diff --git a/db/queue_schema.rb b/db/queue_schema.rb new file mode 100644 index 0000000..85194b6 --- /dev/null +++ b/db/queue_schema.rb @@ -0,0 +1,129 @@ +ActiveRecord::Schema[7.1].define(version: 1) do + create_table "solid_queue_blocked_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.string "concurrency_key", null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release" + t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance" + t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true + end + + create_table "solid_queue_claimed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.bigint "process_id" + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true + t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" + end + + create_table "solid_queue_failed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.text "error" + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true + end + + create_table "solid_queue_jobs", force: :cascade do |t| + t.string "queue_name", null: false + t.string "class_name", null: false + t.text "arguments" + t.integer "priority", default: 0, null: false + t.string "active_job_id" + t.datetime "scheduled_at" + t.datetime "finished_at" + t.string "concurrency_key" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id" + t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name" + t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at" + t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering" + t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting" + end + + create_table "solid_queue_pauses", force: :cascade do |t| + t.string "queue_name", null: false + t.datetime "created_at", null: false + t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true + end + + create_table "solid_queue_processes", force: :cascade do |t| + t.string "kind", null: false + t.datetime "last_heartbeat_at", null: false + t.bigint "supervisor_id" + t.integer "pid", null: false + t.string "hostname" + t.text "metadata" + t.datetime "created_at", null: false + t.string "name", null: false + t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at" + t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true + t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id" + end + + create_table "solid_queue_ready_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true + t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all" + t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue" + end + + create_table "solid_queue_recurring_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "task_key", null: false + t.datetime "run_at", null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true + t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true + end + + create_table "solid_queue_recurring_tasks", force: :cascade do |t| + t.string "key", null: false + t.string "schedule", null: false + t.string "command", limit: 2048 + t.string "class_name" + t.text "arguments" + t.string "queue_name" + t.integer "priority", default: 0 + t.boolean "static", default: true, null: false + t.text "description" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true + t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static" + end + + create_table "solid_queue_scheduled_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "scheduled_at", null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true + t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all" + end + + create_table "solid_queue_semaphores", force: :cascade do |t| + t.string "key", null: false + t.integer "value", default: 1, null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at" + t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value" + t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true + end + + add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..167917c --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,117 @@ +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.0].define(version: 2025_10_20_031401) do + # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" + + create_table "admin_users", force: :cascade do |t| + t.string "email", null: false + t.string "password_digest", null: false + t.string "name", null: false + t.datetime "last_login_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["email"], name: "index_admin_users_on_email", unique: true + end + + create_table "api_keys", force: :cascade do |t| + t.string "name", null: false + t.string "key_digest", null: false + t.string "key_prefix", null: false + t.boolean "active", default: true + t.datetime "last_used_at" + t.datetime "expires_at" + t.jsonb "permissions", default: {} + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["active"], name: "index_api_keys_on_active" + t.index ["key_digest"], name: "index_api_keys_on_key_digest", unique: true + t.index ["key_prefix"], name: "index_api_keys_on_key_prefix" + end + + create_table "gateways", force: :cascade do |t| + t.string "device_id", null: false + t.string "name" + t.string "api_key_digest", null: false + t.string "status", default: "offline" + t.datetime "last_heartbeat_at" + t.integer "messages_sent_today", default: 0 + t.integer "messages_received_today", default: 0 + t.integer "total_messages_sent", default: 0 + t.integer "total_messages_received", default: 0 + t.boolean "active", default: true + t.integer "priority", default: 1 + t.jsonb "metadata", default: {} + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["active"], name: "index_gateways_on_active" + t.index ["device_id"], name: "index_gateways_on_device_id", unique: true + t.index ["status"], name: "index_gateways_on_status" + end + + create_table "otp_codes", force: :cascade do |t| + t.string "phone_number", null: false + t.string "code", null: false + t.string "purpose", default: "authentication" + t.datetime "expires_at", null: false + t.boolean "verified", default: false + t.datetime "verified_at" + t.integer "attempts", default: 0 + t.string "ip_address" + t.jsonb "metadata", default: {} + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["expires_at"], name: "index_otp_codes_on_expires_at" + t.index ["phone_number", "verified", "expires_at"], name: "index_otp_codes_on_phone_number_and_verified_and_expires_at" + t.index ["phone_number"], name: "index_otp_codes_on_phone_number" + end + + create_table "sms_messages", force: :cascade do |t| + t.bigint "gateway_id" + t.string "message_id", null: false + t.string "direction", null: false + t.string "phone_number", null: false + t.text "message_body", null: false + t.string "status", default: "pending" + t.text "error_message" + t.integer "retry_count", default: 0 + t.datetime "sent_at" + t.datetime "delivered_at" + t.datetime "failed_at" + t.jsonb "metadata", default: {} + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["direction"], name: "index_sms_messages_on_direction" + t.index ["gateway_id"], name: "index_sms_messages_on_gateway_id" + t.index ["message_id"], name: "index_sms_messages_on_message_id", unique: true + t.index ["phone_number", "created_at"], name: "index_sms_messages_on_phone_number_and_created_at" + t.index ["phone_number"], name: "index_sms_messages_on_phone_number" + t.index ["status", "created_at"], name: "index_sms_messages_on_status_and_created_at" + end + + create_table "webhook_configs", force: :cascade do |t| + t.string "name", null: false + t.string "url", null: false + t.string "event_type", null: false + t.string "secret_key" + t.boolean "active", default: true + t.integer "timeout", default: 30 + t.integer "retry_count", default: 3 + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["active"], name: "index_webhook_configs_on_active" + t.index ["event_type"], name: "index_webhook_configs_on_event_type" + end + + add_foreign_key "sms_messages", "gateways" +end diff --git a/db/seeds.rb b/db/seeds.rb new file mode 100644 index 0000000..4db034e --- /dev/null +++ b/db/seeds.rb @@ -0,0 +1,143 @@ +# This file should ensure the existence of records required to run the application in every environment (production, +# development, test). The code here should be idempotent so that it can be executed at any point in every environment. +# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). + +puts "🌱 Seeding database..." + +# Create default admin user +puts "\n👤 Creating admin user..." + +unless AdminUser.exists?(email: "admin@example.com") + admin = AdminUser.create!( + email: "admin@example.com", + password: "password123", + name: "Admin User" + ) + puts "✅ Created Admin User" + puts " Email: admin@example.com" + puts " Password: password123" + puts " ⚠️ IMPORTANT: Change this password in production!" +end + +# Create sample API keys for testing +puts "\n📝 Creating API keys..." + +api_key_results = [] + +unless ApiKey.exists?(name: "Development API Key") + result = ApiKey.generate!( + name: "Development API Key", + permissions: { + "send_sms" => true, + "receive_sms" => true, + "manage_otp" => true + } + ) + api_key_results << result + puts "✅ Created API Key: #{result[:raw_key]}" +end + +unless ApiKey.exists?(name: "Admin API Key") + result = ApiKey.generate!( + name: "Admin API Key", + permissions: { + "send_sms" => true, + "receive_sms" => true, + "manage_otp" => true, + "admin" => true + } + ) + api_key_results << result + puts "✅ Created Admin API Key: #{result[:raw_key]}" +end + +# Create sample gateway devices +puts "\n📱 Creating sample gateway devices..." + +unless Gateway.exists?(device_id: "test-gateway-001") + gateway1 = Gateway.new( + device_id: "test-gateway-001", + name: "Test Gateway 1", + status: "offline", + priority: 1, + active: true + ) + raw_key = gateway1.generate_api_key! + puts "✅ Created Gateway: #{gateway1.name}" + puts " Device ID: #{gateway1.device_id}" + puts " API Key: #{raw_key}" +end + +unless Gateway.exists?(device_id: "test-gateway-002") + gateway2 = Gateway.new( + device_id: "test-gateway-002", + name: "Test Gateway 2", + status: "offline", + priority: 2, + active: true + ) + raw_key = gateway2.generate_api_key! + puts "✅ Created Gateway: #{gateway2.name}" + puts " Device ID: #{gateway2.device_id}" + puts " API Key: #{raw_key}" +end + +# Create sample webhook configurations +puts "\n🔗 Creating webhook configurations..." + +WebhookConfig.find_or_create_by!(name: "SMS Received Webhook") do |w| + w.url = "https://example.com/webhooks/sms-received" + w.event_type = "sms_received" + w.secret_key = SecureRandom.hex(32) + w.active = false # Disabled by default + w.timeout = 30 + w.retry_count = 3 +end +puts "✅ Created webhook: SMS Received Webhook" + +WebhookConfig.find_or_create_by!(name: "SMS Sent Webhook") do |w| + w.url = "https://example.com/webhooks/sms-sent" + w.event_type = "sms_sent" + w.secret_key = SecureRandom.hex(32) + w.active = false # Disabled by default + w.timeout = 30 + w.retry_count = 3 +end +puts "✅ Created webhook: SMS Sent Webhook" + +WebhookConfig.find_or_create_by!(name: "SMS Failed Webhook") do |w| + w.url = "https://example.com/webhooks/sms-failed" + w.event_type = "sms_failed" + w.secret_key = SecureRandom.hex(32) + w.active = false # Disabled by default + w.timeout = 30 + w.retry_count = 3 +end +puts "✅ Created webhook: SMS Failed Webhook" + +puts "\n✨ Seeding completed!" +puts "\n" + "=" * 80 +puts "🔑 API KEYS (Save these - they won't be shown again!)" +puts "=" * 80 + +api_key_results.each do |result| + puts "\nName: #{result[:api_key].name}" + puts "Key: #{result[:raw_key]}" + puts "Permissions: #{result[:api_key].permissions}" +end + +puts "\n" + "=" * 80 +puts "📊 Database Summary" +puts "=" * 80 +puts "Admins: #{AdminUser.count}" +puts "Gateways: #{Gateway.count}" +puts "API Keys: #{ApiKey.count}" +puts "Webhooks: #{WebhookConfig.count}" +puts "SMS Messages: #{SmsMessage.count}" +puts "OTP Codes: #{OtpCode.count}" +puts "=" * 80 +puts "\n🔐 Admin Login" +puts "URL: http://localhost:3000/admin/login" +puts "Email: admin@example.com" +puts "Password: password123" +puts "=" * 80 diff --git a/lib/tasks/.keep b/lib/tasks/.keep new file mode 100644 index 0000000..e69de29 diff --git a/log/.keep b/log/.keep new file mode 100644 index 0000000..e69de29 diff --git a/public/400.html b/public/400.html new file mode 100644 index 0000000..282dbc8 --- /dev/null +++ b/public/400.html @@ -0,0 +1,114 @@ + + + + + + + The server cannot process the request due to a client error (400 Bad Request) + + + + + + + + + + + + + +
+
+ +
+
+

The server cannot process the request due to a client error. Please check the request and try again. If you’re the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/404.html b/public/404.html new file mode 100644 index 0000000..c0670bc --- /dev/null +++ b/public/404.html @@ -0,0 +1,114 @@ + + + + + + + The page you were looking for doesn’t exist (404 Not found) + + + + + + + + + + + + + +
+
+ +
+
+

The page you were looking for doesn’t exist. You may have mistyped the address or the page may have moved. If you’re the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/406-unsupported-browser.html b/public/406-unsupported-browser.html new file mode 100644 index 0000000..9532a9c --- /dev/null +++ b/public/406-unsupported-browser.html @@ -0,0 +1,114 @@ + + + + + + + Your browser is not supported (406 Not Acceptable) + + + + + + + + + + + + + +
+
+ +
+
+

Your browser is not supported.
Please upgrade your browser to continue.

+
+
+ + + + diff --git a/public/422.html b/public/422.html new file mode 100644 index 0000000..8bcf060 --- /dev/null +++ b/public/422.html @@ -0,0 +1,114 @@ + + + + + + + The change you wanted was rejected (422 Unprocessable Entity) + + + + + + + + + + + + + +
+
+ +
+
+

The change you wanted was rejected. Maybe you tried to change something you didn’t have access to. If you’re the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/500.html b/public/500.html new file mode 100644 index 0000000..d77718c --- /dev/null +++ b/public/500.html @@ -0,0 +1,114 @@ + + + + + + + We’re sorry, but something went wrong (500 Internal Server Error) + + + + + + + + + + + + + +
+
+ +
+
+

We’re sorry, but something went wrong.
If you’re the application owner check the logs for more information.

+
+
+ + + + diff --git a/public/icon.png b/public/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c4c9dbfbbd2f7c1421ffd5727188146213abbcef GIT binary patch literal 4166 zcmd6qU;WFw?|v@m)Sk^&NvB8tcujdV-r1b=i(NJxn&7{KTb zX$3(M+3TP2o^#KAo{#tIjl&t~(8D-k004kqPglzn0HFG(Q~(I*AKsD#M*g7!XK0T7 zN6P7j>HcT8rZgKl$v!xr806dyN19Bd4C0x_R*I-a?#zsTvb_89cyhuC&T**i|Rc zq5b8M;+{8KvoJ~uj9`u~d_f6`V&3+&ZX9x5pc8s)d175;@pjm(?dapmBcm0&vl9+W zx1ZD2o^nuyUHWj|^A8r>lUorO`wFF;>9XL-Jy!P}UXC{(z!FO%SH~8k`#|9;Q|eue zqWL0^Bp(fg_+Pkm!fDKRSY;+^@BF?AJE zCUWpXPst~hi_~u)SzYBDZroR+Z4xeHIlm_3Yc_9nZ(o_gg!jDgVa=E}Y8uDgem9`b zf=mfJ_@(BXSkW53B)F2s!&?_R4ptb1fYXlF++@vPhd=marQgEGRZS@B4g1Mu?euknL= z67P~tZ?*>-Hmi7GwlisNHHJDku-dSm7g@!=a}9cSL6Pa^w^2?&?$Oi8ibrr>w)xqx zOH_EMU@m05)9kuNR>>4@H%|){U$^yvVQ(YgOlh;5oU_-vivG-p4=LrN-k7D?*?u1u zsWly%tfAzKd6Fb=`eU2un_uaTXmcT#tlOL+aRS=kZZf}A7qT8lvcTx~7j` z*b>=z)mwg7%B2_!D0!1IZ?Nq{^Y$uI4Qx*6T!E2Col&2{k?ImCO=dD~A&9f9diXy^$x{6CwkBimn|1E09 zAMSezYtiL?O6hS37KpvDM?22&d{l)7h-!F)C-d3j8Z`c@($?mfd{R82)H>Qe`h{~G z!I}(2j(|49{LR?w4Jspl_i!(4T{31|dqCOpI52r5NhxYV+cDAu(xp*4iqZ2e-$YP= zoFOPmm|u*7C?S{Fp43y+V;>~@FFR76bCl@pTtyB93vNWy5yf;HKr8^0d7&GVIslYm zo3Tgt@M!`8B6IW&lK{Xk>%zp41G%`(DR&^u z5^pwD4>E6-w<8Kl2DzJ%a@~QDE$(e87lNhy?-Qgep!$b?5f7+&EM7$e>|WrX+=zCb z=!f5P>MxFyy;mIRxjc(H*}mceXw5a*IpC0PEYJ8Y3{JdoIW)@t97{wcUB@u+$FCCO z;s2Qe(d~oJC^`m$7DE-dsha`glrtu&v&93IZadvl_yjp!c89>zo;Krk+d&DEG4?x$ zufC1n+c1XD7dolX1q|7}uelR$`pT0Z)1jun<39$Sn2V5g&|(j~Z!wOddfYiZo7)A< z!dK`aBHOOk+-E_xbWCA3VR-+o$i5eO9`rMI#p_0xQ}rjEpGW;U!&&PKnivOcG(|m9 z!C8?WC6nCXw25WVa*eew)zQ=h45k8jSIPbq&?VE{oG%?4>9rwEeB4&qe#?-y_es4c|7ufw%+H5EY#oCgv!Lzv291#-oNlX~X+Jl5(riC~r z=0M|wMOP)Tt8@hNg&%V@Z9@J|Q#K*hE>sr6@oguas9&6^-=~$*2Gs%h#GF@h)i=Im z^iKk~ipWJg1VrvKS;_2lgs3n1zvNvxb27nGM=NXE!D4C!U`f*K2B@^^&ij9y}DTLB*FI zEnBL6y{jc?JqXWbkIZd7I16hA>(f9T!iwbIxJj~bKPfrO;>%*5nk&Lf?G@c2wvGrY&41$W{7HM9+b@&XY@>NZM5s|EK_Dp zQX60CBuantx>|d#DsaZ*8MW(we|#KTYZ=vNa#d*DJQe6hr~J6{_rI#?wi@s|&O}FR zG$kfPxheXh1?IZ{bDT-CWB4FTvO-k5scW^mi8?iY5Q`f8JcnnCxiy@m@D-%lO;y0pTLhh6i6l@x52j=#^$5_U^os}OFg zzdHbo(QI`%9#o*r8GCW~T3UdV`szO#~)^&X_(VW>o~umY9-ns9-V4lf~j z`QBD~pJ4a#b`*6bJ^3RS5y?RAgF7K5$ll97Y8#WZduZ`j?IEY~H(s^doZg>7-tk*t z4_QE1%%bb^p~4F5SB$t2i1>DBG1cIo;2(xTaj*Y~hlM{tSDHojL-QPg%Mo%6^7FrpB*{ z4G0@T{-77Por4DCMF zB_5Y~Phv%EQ64W8^GS6h?x6xh;w2{z3$rhC;m+;uD&pR74j+i22P5DS-tE8ABvH(U~indEbBUTAAAXfHZg5QpB@TgV9eI<)JrAkOI z8!TSOgfAJiWAXeM&vR4Glh;VxH}WG&V$bVb`a`g}GSpwggti*&)taV1@Ak|{WrV|5 zmNYx)Ans=S{c52qv@+jmGQ&vd6>6yX6IKq9O$3r&0xUTdZ!m1!irzn`SY+F23Rl6# zFRxws&gV-kM1NX(3(gnKpGi0Q)Dxi~#?nyzOR9!en;Ij>YJZVFAL*=R%7y%Mz9hU% zs>+ZB?qRmZ)nISx7wxY)y#cd$iaC~{k0avD>BjyF1q^mNQ1QcwsxiTySe<6C&cC6P zE`vwO9^k-d`9hZ!+r@Jnr+MF*2;2l8WjZ}DrwDUHzSF{WoG zucbSWguA!3KgB3MU%HH`R;XqVv0CcaGq?+;v_A5A2kpmk5V%qZE3yzQ7R5XWhq=eR zyUezH=@V)y>L9T-M-?tW(PQYTRBKZSVb_!$^H-Pn%ea;!vS_?M<~Tm>_rWIW43sPW z=!lY&fWc1g7+r?R)0p8(%zp&vl+FK4HRkns%BW+Up&wK8!lQ2~bja|9bD12WrKn#M zK)Yl9*8$SI7MAwSK$%)dMd>o+1UD<2&aQMhyjS5R{-vV+M;Q4bzl~Z~=4HFj_#2V9 zB)Gfzx3ncy@uzx?yzi}6>d%-?WE}h7v*w)Jr_gBl!2P&F3DX>j_1#--yjpL%<;JMR z*b70Gr)MMIBWDo~#<5F^Q0$VKI;SBIRneuR7)yVsN~A9I@gZTXe)E?iVII+X5h0~H zx^c(fP&4>!*q>fb6dAOC?MI>Cz3kld#J*;uik+Ps49cwm1B4 zZc1|ZxYyTv;{Z!?qS=D)sgRKx^1AYf%;y_V&VgZglfU>d+Ufk5&LV$sKv}Hoj+s; xK3FZRYdhbXT_@RW*ff3@`D1#ps#~H)p+y&j#(J|vk^lW{fF9OJt5(B-_&*Xgn9~3N literal 0 HcmV?d00001 diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..04b34bf --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..c19f78a --- /dev/null +++ b/public/robots.txt @@ -0,0 +1 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file diff --git a/script/.keep b/script/.keep new file mode 100644 index 0000000..e69de29 diff --git a/storage/.keep b/storage/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb new file mode 100644 index 0000000..cee29fd --- /dev/null +++ b/test/application_system_test_case.rb @@ -0,0 +1,5 @@ +require "test_helper" + +class ApplicationSystemTestCase < ActionDispatch::SystemTestCase + driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ] +end diff --git a/test/controllers/.keep b/test/controllers/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/fixtures/admin_users.yml b/test/fixtures/admin_users.yml new file mode 100644 index 0000000..c28319e --- /dev/null +++ b/test/fixtures/admin_users.yml @@ -0,0 +1,13 @@ +# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +one: + email: MyString + password_digest: MyString + name: MyString + last_login_at: 2025-10-20 10:51:35 + +two: + email: MyString + password_digest: MyString + name: MyString + last_login_at: 2025-10-20 10:51:35 diff --git a/test/fixtures/files/.keep b/test/fixtures/files/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/helpers/.keep b/test/helpers/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/integration/.keep b/test/integration/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/mailers/.keep b/test/mailers/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/models/.keep b/test/models/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/models/admin_user_test.rb b/test/models/admin_user_test.rb new file mode 100644 index 0000000..718fba3 --- /dev/null +++ b/test/models/admin_user_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class AdminUserTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/system/.keep b/test/system/.keep new file mode 100644 index 0000000..e69de29 diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..0c22470 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,15 @@ +ENV["RAILS_ENV"] ||= "test" +require_relative "../config/environment" +require "rails/test_help" + +module ActiveSupport + class TestCase + # Run tests in parallel with specified workers + parallelize(workers: :number_of_processors) + + # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. + fixtures :all + + # Add more helper methods to be used by all tests here... + end +end diff --git a/test_gateway_auth.rb b/test_gateway_auth.rb new file mode 100644 index 0000000..1bef5df --- /dev/null +++ b/test_gateway_auth.rb @@ -0,0 +1,70 @@ +#!/usr/bin/env ruby +# Test script to verify gateway API key authentication +# Usage: ruby test_gateway_auth.rb + +require_relative 'config/environment' +require 'digest' + +api_key = ARGV[0] + +if api_key.nil? || api_key.empty? + puts "Usage: ruby test_gateway_auth.rb " + puts "" + puts "Example:" + puts " ruby test_gateway_auth.rb gw_live_abc123..." + exit 1 +end + +puts "Testing API Key Authentication" +puts "=" * 60 +puts "API Key: #{api_key[0..20]}..." # Show only first part for security +puts "" + +# Hash the API key +api_key_digest = Digest::SHA256.hexdigest(api_key) +puts "Key Digest: #{api_key_digest[0..20]}..." +puts "" + +# Find gateway +gateway = Gateway.find_by(api_key_digest: api_key_digest) + +if gateway.nil? + puts "❌ FAILED: No gateway found with this API key" + puts "" + puts "Possible issues:" + puts " 1. API key is incorrect" + puts " 2. Gateway was deleted" + puts " 3. API key was regenerated" + puts "" + puts "Available active gateways:" + Gateway.where(active: true).each do |g| + puts " - #{g.name} (#{g.device_id})" + end + exit 1 +end + +puts "✅ Gateway Found!" +puts "-" * 60 +puts "Name: #{gateway.name}" +puts "Device ID: #{gateway.device_id}" +puts "Status: #{gateway.status}" +puts "Active: #{gateway.active}" +puts "Created: #{gateway.created_at}" +puts "" + +if !gateway.active + puts "⚠️ WARNING: Gateway is NOT active" + puts "The WebSocket connection will be rejected" + puts "" + puts "To activate:" + puts " Gateway.find(#{gateway.id}).update!(active: true)" + exit 1 +end + +puts "✅ Authentication would succeed!" +puts "" +puts "WebSocket URL to use:" +puts " ws://localhost:3000/cable?api_key=#{api_key}" +puts "" +puts "Or from network:" +puts " ws://YOUR_IP:3000/cable?api_key=#{api_key}" diff --git a/tmp/.keep b/tmp/.keep new file mode 100644 index 0000000..e69de29 diff --git a/tmp/pids/.keep b/tmp/pids/.keep new file mode 100644 index 0000000..e69de29 diff --git a/tmp/storage/.keep b/tmp/storage/.keep new file mode 100644 index 0000000..e69de29 diff --git a/vendor/.keep b/vendor/.keep new file mode 100644 index 0000000..e69de29 diff --git a/vendor/javascript/.keep b/vendor/javascript/.keep new file mode 100644 index 0000000..e69de29