Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ POSTGRES_PORT=

USER_DB=
PASSWORD_DB=

ADMIN_TOKEN=
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,36 @@ curl -X POST 'http://localhost:3000/v1/sismos/1/reports' \
- `404 Not Found` — the referenced event does not exist
- `429 Too Many Requests` — rate limit exceeded (see [Rate Limiting](#rate-limiting))

**Register a web notification device**

```http
POST /v1/devices
```

```bash
curl -X POST 'http://localhost:3000/v1/devices' \
-H 'Content-Type: application/json' \
-d '{"fcm_token":"token-from-firebase-messaging"}'
```

The token is unique and is stored with `platform: "web"`.

**List or remove notification devices**

```http
GET /v1/devices
DELETE /v1/devices/:id
```

These endpoints require the administrative header:

```http
X-Admin-Token: <ADMIN_TOKEN>
```

`GET /v1/devices` returns only the FCM token strings so the notification
service can send alerts without accessing the database directly.

---

## Rate Limiting
Expand All @@ -200,6 +230,7 @@ Public write endpoints are protected against abuse via [`rack-attack`](https://g
|---|---|---|
| Global | 60 requests/minute | Per IP, all endpoints except `/assets` |
| Reports | 5 requests/minute | Per IP, `POST /v1/sismos/:id/reports` only |
| Devices | 5 requests/minute | Per IP, `POST /v1/devices` only |

Exceeding a limit returns `429 Too Many Requests` with a `Retry-After` header and a JSON error body:

Expand Down
15 changes: 15 additions & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
@@ -1,2 +1,17 @@
class ApplicationController < ActionController::API
private

def authenticate_admin!
configured_token = ENV['ADMIN_TOKEN'].presence
supplied_token = request.headers['X-Admin-Token'].presence

return render_unauthorized unless configured_token && supplied_token
return render_unauthorized unless supplied_token.bytesize == configured_token.bytesize

render_unauthorized unless ActiveSupport::SecurityUtils.secure_compare(supplied_token, configured_token)
end

def render_unauthorized
render json: { error: 'Unauthorized' }, status: :unauthorized
end
end
42 changes: 42 additions & 0 deletions app/controllers/devices_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
class DevicesController < ApplicationController
before_action :authenticate_admin!, only: %i[index destroy]

def index
no_store
render json: { data: Device.order(:id).pluck(:fcm_token) }
end
Comment thread
Euler-B marked this conversation as resolved.

def create
token = device_params[:fcm_token]
device = Device.find_or_initialize_by(fcm_token: token)
new_device = device.new_record?
device.assign_attributes(device_params)

if device.save
render_device_json(device, status: new_device ? :created : :ok)
else
render json: { errors: device.errors.full_messages }, status: :unprocessable_entity
end
Comment thread
Euler-B marked this conversation as resolved.
rescue ActiveRecord::RecordNotUnique
existing_device = Device.find_by!(fcm_token: token)
render_device_json(existing_device, status: :ok)
end

def destroy
device = Device.find_by(id: params[:id])
return render json: { error: 'Device not found' }, status: :not_found unless device

device.destroy!
head :no_content
end

private

def render_device_json(device, status:)
render json: { data: { id: device.id, type: 'device' } }, status: status
end

def device_params
params.permit(:fcm_token, :platform)
end
end
10 changes: 10 additions & 0 deletions app/models/device.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class Device < ApplicationRecord
MAX_FCM_TOKEN_LENGTH = 255

validates :fcm_token,
presence: true,
uniqueness: true,
length: { maximum: MAX_FCM_TOKEN_LENGTH },
format: { without: /\s/ }
validates :platform, inclusion: { in: %w[web] }
end
2 changes: 1 addition & 1 deletion config/initializers/cors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@

resource '/v1/*',
headers: :any,
methods: %i[get post options]
methods: %i[get post delete options]
end
end
7 changes: 6 additions & 1 deletion config/initializers/rack_attack.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,12 @@ class Attack
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)
# 3. Throttle device registration by IP (5 req/min)
throttle('devices/ip', limit: 5, period: 1.minute) do |req|
req.ip if req.path_info.match?(%r{\A/v1/devices(?:\.[^/]+)?\z}) && req.post?
end

# 4. 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
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
get 'stats', on: :collection
resources :reports, only: [:create]
end
resources :devices, only: %i[index create destroy]
Comment thread
Euler-B marked this conversation as resolved.
end
end
12 changes: 12 additions & 0 deletions db/migrate/20260805000000_create_devices.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class CreateDevices < ActiveRecord::Migration[7.2]
def change
create_table :devices do |t|
t.string :fcm_token, null: false, limit: 255
t.string :platform, null: false, default: 'web'
t.timestamps
end

add_index :devices, :fcm_token, unique: true
Comment thread
Euler-B marked this conversation as resolved.
add_check_constraint :devices, "platform = 'web'", name: 'devices_platform_is_web'
end
end
11 changes: 10 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

98 changes: 98 additions & 0 deletions test/controllers/devices_controller_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
require 'test_helper'

class DevicesControllerTest < ActionDispatch::IntegrationTest
setup do
@admin_token = 'test-admin-token'
ENV['ADMIN_TOKEN'] = @admin_token
end

teardown do
ENV.delete('ADMIN_TOKEN')
end

test 'creates a device publicly and does not duplicate its token' do
assert_difference('Device.count', 1) do
post devices_url, params: { fcm_token: 'token-1' }, as: :json
end
assert_response :created

post devices_url, params: { fcm_token: 'token-1' }, as: :json
assert_response :success
assert_equal 1, Device.where(fcm_token: 'token-1').count
end

test 'rejects invalid devices' do
post devices_url, params: { fcm_token: '' }, as: :json

assert_response :unprocessable_entity
end

test 'requires the admin token to list devices' do
get devices_url
assert_response :unauthorized
assert_equal({ 'error' => 'Unauthorized' }, JSON.parse(response.body))

get devices_url, headers: { 'X-Admin-Token' => @admin_token }
assert_response :success
assert_includes response.headers['Cache-Control'], 'no-store'
assert_equal({ 'data' => [] }, JSON.parse(response.body))
end

test 'requires the admin token to destroy a device' do
device = Device.create!(fcm_token: 'token-1')

delete device_url(device)
assert_response :unauthorized
assert_equal({ 'error' => 'Unauthorized' }, JSON.parse(response.body))

delete device_url(device), headers: { 'X-Admin-Token' => @admin_token }
assert_response :no_content
assert_not Device.exists?(device.id)
end

test 'rejects unauthorized requests with mismatched token length or invalid token' do
get devices_url, headers: { 'X-Admin-Token' => 'short' }
assert_response :unauthorized
assert_equal({ 'error' => 'Unauthorized' }, JSON.parse(response.body))

get devices_url, headers: { 'X-Admin-Token' => 'wrong-admin-token' }
assert_response :unauthorized
assert_equal({ 'error' => 'Unauthorized' }, JSON.parse(response.body))
end

test 'fails closed when ADMIN_TOKEN is not configured' do
ENV.delete('ADMIN_TOKEN')

get devices_url, headers: { 'X-Admin-Token' => 'test-admin-token' }

assert_response :unauthorized
assert_equal({ 'error' => 'Unauthorized' }, JSON.parse(response.body))
end

test 'rescues ActiveRecord::RecordNotUnique on concurrent device creation' do
existing = Device.create!(fcm_token: 'token-concurrent')

begin
Device.define_method(:save) { raise ActiveRecord::RecordNotUnique }
post devices_url, params: { fcm_token: 'token-concurrent' }, as: :json
ensure
Device.remove_method(:save)
end

assert_response :ok
assert_equal({ 'data' => { 'id' => existing.id, 'type' => 'device' } }, JSON.parse(response.body))
end

test 'supports CORS preflight for DELETE requests' do
device = Device.create!(fcm_token: 'token-cors')
origin = ENV.fetch('ALLOWED_ORIGIN', 'http://localhost:5173')

process :options, device_url(device), headers: {
'Origin' => origin,
'Access-Control-Request-Method' => 'DELETE'
}

assert_response :success
assert_includes response.headers['Access-Control-Allow-Methods'], 'DELETE'
end
end
12 changes: 12 additions & 0 deletions test/integration/rate_limiting_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,16 @@ class RateLimitingTest < ActionDispatch::IntegrationTest
assert_includes json['error'], 'Rate limit exceeded'
end
end

test 'returns 429 when POST device rate limit is exceeded' do
travel_to Time.utc(2026, 8, 4, 12, 0, 0) do
5.times do |index|
post devices_url, params: { fcm_token: "token-#{index}" }, as: :json
assert_response :success
end

post devices_url, params: { fcm_token: 'token-5' }, as: :json
assert_response :too_many_requests
end
end
end
39 changes: 39 additions & 0 deletions test/models/device_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
require 'test_helper'

class DeviceTest < ActiveSupport::TestCase
test 'requires a unique FCM token' do
device = Device.new(fcm_token: 'token-1')
assert device.save!

duplicate = Device.new(fcm_token: 'token-1')
assert_not duplicate.valid?
assert_includes duplicate.errors[:fcm_token], 'has already been taken'
end

test 'defaults to the web platform' do
assert_equal 'web', Device.new(fcm_token: 'token-1').platform
end

test 'only accepts the web platform' do
device = Device.new(fcm_token: 'token-1', platform: 'ios')

assert_not device.valid?
assert_includes device.errors[:platform], 'is not included in the list'
end
Comment thread
Euler-B marked this conversation as resolved.

test 'rejects invalid FCM tokens containing whitespace, control characters, or exceeding maximum length' do
invalid_tokens = [
'token with space',
"token\nwith\nnewline",
"token\rwith\rreturn",
"token\twith\ttab",
'a' * (Device::MAX_FCM_TOKEN_LENGTH + 1)
]

invalid_tokens.each do |invalid_token|
device = Device.new(fcm_token: invalid_token)
assert_not device.valid?, "Expected device with token #{invalid_token.inspect} to be invalid"
assert_not_empty device.errors[:fcm_token]
end
end
end
Loading