diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 6d606b8..5058aef 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -32,7 +32,7 @@ reviews: - "!vendor/**" - "!storage/**" - "!Gemfile.lock" - - "!**.keep" + - "!**/.keep" path_instructions: # ── Models ────────────────────────────────────────────────────────── diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a519a55..2688798 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,11 +3,15 @@ name: "Release Telurify-API" on: push: tags: - - "v*.*.*" + - "v[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+-*" + +concurrency: + group: release-${{ github.repository }} + cancel-in-progress: false permissions: - contents: write - packages: write + contents: read jobs: test: @@ -15,7 +19,7 @@ jobs: runs-on: ubuntu-latest services: postgres: - image: postgres:16-alpine + image: postgres@sha256:57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777 ports: - "5432:5432" env: @@ -33,10 +37,12 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Install Ruby and gems - uses: ruby/setup-ruby@v1 + uses: ruby/setup-ruby@3612d7c07b4618a385f95b9d36a3e5e40e69a04a # v1.222.0 with: bundler-cache: true @@ -56,13 +62,26 @@ jobs: name: Build & Publish Release needs: test runs-on: ubuntu-latest + # Requires contents: write to create GitHub releases and packages: write to push Docker images to GHCR + permissions: + contents: write + packages: write steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + + - name: Validate SemVer tag + run: | + if ! [[ "${{ github.ref_name }}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then + echo "Invalid SemVer tag: ${{ github.ref_name }}" + exit 1 + fi - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@9780b0c442fbb11e7d0cfd199dd156f3e0d7b114 # v3.3.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -70,15 +89,15 @@ jobs: - name: Extract metadata for Docker id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@902fa8ec7d6ecbf8d84d576c707f25b1f504f739 # v5.7.0 with: images: ghcr.io/${{ github.repository }} tags: | type=semver,pattern={{version}} - type=raw,value=latest + type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }} - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@471d1dc4e07e5cd41321f5301416d09a7fed8192 # v6.15.0 with: context: . file: ./Dockerfile @@ -87,6 +106,7 @@ jobs: labels: ${{ steps.meta.outputs.labels }} - name: Create GitHub Release - uses: softprops/action-gh-release@v1 - with: - generate_release_notes: true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ github.ref_name }} + run: gh release create "$RELEASE_TAG" --generate-notes diff --git a/Gemfile b/Gemfile index 9695e40..f11513c 100644 --- a/Gemfile +++ b/Gemfile @@ -59,3 +59,6 @@ gem 'csv' gem 'will_paginate', '~> 4.0' gem 'rack-cors', '~> 2.0', '>= 2.0.2' + +gem 'rack-attack', '~> 6.7' +gem 'redis', '>= 4.0.1' diff --git a/Gemfile.lock b/Gemfile.lock index 176c149..cd5ca29 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -176,6 +176,8 @@ GEM nio4r (~> 2.0) racc (1.8.1) rack (3.2.6) + rack-attack (6.8.0) + rack (>= 1.0, < 4) rack-cors (2.0.2) rack (>= 2.0.0) rack-session (2.1.2) @@ -227,6 +229,10 @@ GEM prism (>= 1.6.0) rbs (>= 4.0.0) tsort + redis (6.0.0) + redis-client (= 0.30.1) + redis-client (0.30.1) + connection_pool regexp_parser (2.12.0) reline (0.6.3) io-console (~> 0.5) @@ -281,8 +287,10 @@ DEPENDENCIES minitest (~> 5.22) pg (~> 1.1) puma (>= 5.0) + rack-attack (~> 6.7) rack-cors (~> 2.0, >= 2.0.2) rails (~> 7.2.3, >= 7.2.3.2) + redis (>= 4.0.1) rubocop (~> 1) tzinfo-data will_paginate (~> 4.0) diff --git a/app/controllers/sismos_controller.rb b/app/controllers/sismos_controller.rb index 7149103..41c2779 100644 --- a/app/controllers/sismos_controller.rb +++ b/app/controllers/sismos_controller.rb @@ -13,6 +13,11 @@ def index render json: serialized_sismos end + def stats + stats_data = calculate_stats + render json: serialize_stats(stats_data) + end + private def filter_sismos @@ -172,4 +177,38 @@ def serialize_sismos(sismos) } } end + + def calculate_stats + max_sismo = Sismo.where.not(mag: nil).order(mag: :desc).first + + { + total_sismos: Sismo.count, + last_24h_count: Sismo.where('created_at >= ?', 24.hours.ago).count, + tsunami_count: Sismo.by_tsunami('true').count, + max_magnitude: serialize_max_magnitude(max_sismo), + by_mag_type: Sismo.group(:magType).count + } + end + + def serialize_max_magnitude(sismo) + return nil unless sismo + + { + id: sismo.id, + title: sismo.title, + magnitude: sismo.mag, + place: sismo.place, + time: sismo.created_at.to_s + } + end + + def serialize_stats(stats_data) + { + data: { + id: 'stats', + type: 'stats', + attributes: stats_data + } + } + end end diff --git a/config/application.rb b/config/application.rb index c7b1654..f08443f 100644 --- a/config/application.rb +++ b/config/application.rb @@ -24,6 +24,9 @@ class Application < Rails::Application end end + # Enable Rack::Attack middleware for rate limiting and throttling + config.middleware.use Rack::Attack + # Configuration for the application, engines, and railties goes here. # # These settings can be overridden in specific environments using the files diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb new file mode 100644 index 0000000..d5ebd2d --- /dev/null +++ b/config/initializers/rack_attack.rb @@ -0,0 +1,48 @@ +module Rack + class Attack + # Cache store for rate limit counters. + # Uses RedisCacheStore if RACK_ATTACK_REDIS_URL or REDIS_URL is present. + # Falls back gracefully to MemoryStore if Redis is not configured yet. + redis_url = ENV['RACK_ATTACK_REDIS_URL'].presence || ENV['REDIS_URL'].presence + + Rack::Attack.cache.store = if redis_url + ActiveSupport::Cache::RedisCacheStore.new( + url: redis_url, + namespace: 'rack_attack' + ) + elsif Rails.env.production? + raise 'RACK_ATTACK_REDIS_URL or REDIS_URL is required in production' + else + ActiveSupport::Cache::MemoryStore.new + end + + # 1. Throttle all requests by IP (60 req/min) + throttle('req/ip', limit: 60, period: 1.minute) do |req| + req.ip unless req.path.start_with?('/assets') + end + + # 2. Throttle POST reports endpoint by IP (5 req/min) to prevent spam + throttle('reports/ip', limit: 5, period: 1.minute) do |req| + req.ip if req.path_info.match?(%r{\A/v1/sismos/\d+/reports(?:\.[^/]+)?\z}) && req.post? + end + + # 3. Custom Response for Throttled Requests (HTTP 429) + self.throttled_responder = lambda do |request| + match_data = request.env['rack.attack.match_data'] || {} + now = match_data[:epoch_time] || Time.now.to_i + period = match_data[:period] || 60 + retry_after = period - (now % period) + + headers = { + 'Content-Type' => 'application/json; charset=utf-8', + 'Retry-After' => retry_after.to_s + } + + body = { + error: "Rate limit exceeded. Try again in #{retry_after} seconds." + }.to_json + + [429, headers, [body]] + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 574991d..53e41a4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,7 @@ Rails.application.routes.draw do scope '/v1' do resources :sismos, only: [:index] do + get 'stats', on: :collection resources :reports, only: [:create] end end diff --git a/test/controllers/sismos_controller_test.rb b/test/controllers/sismos_controller_test.rb index c95d4a1..07528e7 100644 --- a/test/controllers/sismos_controller_test.rb +++ b/test/controllers/sismos_controller_test.rb @@ -108,4 +108,21 @@ class SismosControllerTest < ActionDispatch::IntegrationTest json = JSON.parse(response.body) assert_equal 'Invalid value for filter: mag_max', json['error'] end + + # ── Stats endpoint ──────────────────────────────────────────────── + test 'should get stats with calculated metrics' do + get stats_sismos_url + assert_response :success + + json = JSON.parse(response.body) + assert_equal 'stats', json['data']['id'] + assert_equal 'stats', json['data']['type'] + + attrs = json['data']['attributes'] + assert_equal Sismo.count, attrs['total_sismos'] + assert_equal Sismo.where('created_at >= ?', 24.hours.ago).count, attrs['last_24h_count'] + assert_equal 1, attrs['tsunami_count'] + assert_equal 7.8, attrs['max_magnitude']['magnitude'] + assert attrs['by_mag_type'].key?('ml') + end end diff --git a/test/integration/rate_limiting_test.rb b/test/integration/rate_limiting_test.rb new file mode 100644 index 0000000..1c79302 --- /dev/null +++ b/test/integration/rate_limiting_test.rb @@ -0,0 +1,65 @@ +require 'test_helper' + +class RateLimitingTest < ActionDispatch::IntegrationTest + setup do + Rack::Attack.cache.store.clear + end + + test 'allows requests under the rate limit' do + get sismos_url + assert_response :success + end + + test 'returns 429 too many requests when general rate limit is exceeded' do + travel_to Time.utc(2026, 8, 4, 12, 0, 0) do + 60.times do + get sismos_url + assert_response :success + end + + get sismos_url + assert_response :too_many_requests + + json = JSON.parse(response.body) + assert json.key?('error') + assert_includes json['error'], 'Rate limit exceeded' + assert response.headers.key?('Retry-After') + end + end + + test 'returns 429 when POST report rate limit is exceeded' do + sismo = sismos(:one) + + travel_to Time.utc(2026, 8, 4, 12, 0, 0) do + 5.times do + post sismo_reports_url(sismo), params: { felt: true, intensity: 'moderate' }, as: :json + assert_response :created + end + + post sismo_reports_url(sismo), params: { felt: true, intensity: 'moderate' }, as: :json + assert_response :too_many_requests + + json = JSON.parse(response.body) + assert json.key?('error') + assert_includes json['error'], 'Rate limit exceeded' + end + end + + test 'returns 429 when POST report rate limit is exceeded on format-suffixed path' do + sismo = sismos(:one) + + travel_to Time.utc(2026, 8, 4, 12, 0, 0) do + 5.times do + post "#{sismo_reports_path(sismo)}.json", params: { felt: true, intensity: 'moderate' }, as: :json + assert_response :created + end + + post "#{sismo_reports_path(sismo)}.json", params: { felt: true, intensity: 'moderate' }, as: :json + assert_response :too_many_requests + + json = JSON.parse(response.body) + assert json.key?('error') + assert_includes json['error'], 'Rate limit exceeded' + end + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 1b7300e..094cf9a 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -10,6 +10,10 @@ class TestCase # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. fixtures :all + setup do + Rack::Attack.cache.store.clear if defined?(Rack::Attack) + end + # Add more helper methods to be used by all tests here... end end