diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 855a724..88df93f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,3 +1,17 @@ class ApplicationController < ActionController::API include FacetRailsCommon::ApplicationControllerMethods + + # Ensure all API endpoints support CORS when Origin header is present + before_action :set_cors_headers + + private + + def set_cors_headers + if request.headers['Origin'] + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD' + response.headers['Access-Control-Allow-Headers'] = 'Origin, Content-Type, Accept, Authorization, X-Requested-With' + response.headers['Cross-Origin-Resource-Policy'] = 'cross-origin' + end + end end diff --git a/app/controllers/ethscriptions_controller.rb b/app/controllers/ethscriptions_controller.rb index 4221548..ddb3787 100644 --- a/app/controllers/ethscriptions_controller.rb +++ b/app/controllers/ethscriptions_controller.rb @@ -120,12 +120,27 @@ def data blockhash, block_number = scope.pick(:block_blockhash, :block_number) unless blockhash.present? + # Ensure CORS headers are set even for 404 responses + if request.headers['Origin'] + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = 'GET, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = 'Origin, Content-Type, Accept, Authorization' + response.headers['Cross-Origin-Resource-Policy'] = 'cross-origin' + end head 404 return end response.headers.delete('X-Frame-Options') + # Ensure CORS headers are set for cross-origin requests + if request.headers['Origin'] + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = 'GET, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = 'Origin, Content-Type, Accept, Authorization' + response.headers['Cross-Origin-Resource-Policy'] = 'cross-origin' + end + set_cache_control_headers( max_age: 6, s_max_age: 1.minute, @@ -140,6 +155,17 @@ def data end end + def data_options + # Handle CORS preflight requests for the data endpoint + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = 'GET, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = 'Origin, Content-Type, Accept, Authorization' + response.headers['Access-Control-Max-Age'] = '3600' + response.headers['Cross-Origin-Resource-Policy'] = 'cross-origin' + + head :ok + end + def attachment scope = Ethscription.all @@ -154,12 +180,27 @@ def attachment attachment_scope = EthscriptionAttachment.where(sha: sha) unless attachment_scope.exists? + # Ensure CORS headers are set even for 404 responses + if request.headers['Origin'] + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = 'GET, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = 'Origin, Content-Type, Accept, Authorization' + response.headers['Cross-Origin-Resource-Policy'] = 'cross-origin' + end head 404 return end response.headers.delete('X-Frame-Options') + # Ensure CORS headers are set for cross-origin requests + if request.headers['Origin'] + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = 'GET, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = 'Origin, Content-Type, Accept, Authorization' + response.headers['Cross-Origin-Resource-Policy'] = 'cross-origin' + end + set_cache_control_headers( max_age: 6, s_max_age: 1.minute, @@ -172,6 +213,17 @@ def attachment end end + def attachment_options + # Handle CORS preflight requests for the attachment endpoint + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = 'GET, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = 'Origin, Content-Type, Accept, Authorization' + response.headers['Access-Control-Max-Age'] = '3600' + response.headers['Cross-Origin-Resource-Policy'] = 'cross-origin' + + head :ok + end + def exists existing = Ethscription.find_by_content_sha(params[:sha]) diff --git a/config/routes.rb b/config/routes.rb index 0aafc24..169bb32 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,6 +3,7 @@ def draw_routes resources :ethscriptions, only: [:index, :show] do collection do get "/:id/data", to: "ethscriptions#data" + match "/:id/data", to: "ethscriptions#data_options", via: :options # get "/newer_ethscriptions", to: "ethscriptions#newer_ethscriptions" # get "/newer", to: "ethscriptions#newer_ethscriptions" get '/owned_by/:owned_by_address', to: 'ethscriptions#index' @@ -12,6 +13,7 @@ def draw_routes member do get 'attachment', to: 'ethscriptions#attachment' + match 'attachment', to: 'ethscriptions#attachment_options', via: :options end end diff --git a/spec/requests/cors_spec.rb b/spec/requests/cors_spec.rb new file mode 100644 index 0000000..04f4370 --- /dev/null +++ b/spec/requests/cors_spec.rb @@ -0,0 +1,100 @@ +require 'rails_helper' + +RSpec.describe 'CORS for Data and Attachment Endpoints', type: :request do + describe 'Ethscriptions data endpoint CORS handling' do + context 'OPTIONS preflight request' do + it 'responds with appropriate CORS headers for preflight request' do + options "/ethscriptions/1/data", + headers: { + 'Origin' => 'https://example.com', + 'Access-Control-Request-Method' => 'GET', + 'Access-Control-Request-Headers' => 'content-type' + } + + expect(response.status).to eq(200) + expect(response.headers['Access-Control-Allow-Origin']).to eq('*') + expect(response.headers['Access-Control-Allow-Methods']).to eq('GET, OPTIONS') + expect(response.headers['Access-Control-Allow-Headers']).to eq('Origin, Content-Type, Accept, Authorization') + expect(response.headers['Access-Control-Max-Age']).to eq('3600') + expect(response.headers['Cross-Origin-Resource-Policy']).to eq('cross-origin') + end + end + + context 'GET request with Origin header' do + it 'includes CORS headers when Origin header is present' do + # Note: This test may fail if ethscription #1 doesn't exist, but the CORS headers should still be set + get "/ethscriptions/1/data", + headers: { 'Origin' => 'https://example.com' } + + # Check CORS headers are present regardless of whether the ethscription exists + expect(response.headers['Access-Control-Allow-Origin']).to eq('*') + expect(response.headers['Access-Control-Allow-Methods']).to eq('GET, OPTIONS') + expect(response.headers['Access-Control-Allow-Headers']).to eq('Origin, Content-Type, Accept, Authorization') + expect(response.headers['Cross-Origin-Resource-Policy']).to eq('cross-origin') + end + + it 'does not include CORS headers when Origin header is not present' do + get "/ethscriptions/1/data" + + # CORS headers should not be set when no Origin header is present + expect(response.headers['Access-Control-Allow-Origin']).to be_nil + end + end + end + + describe 'Ethscriptions attachment endpoint CORS handling' do + context 'OPTIONS preflight request' do + it 'responds with appropriate CORS headers for preflight request' do + options "/ethscriptions/1/attachment", + headers: { + 'Origin' => 'https://example.com', + 'Access-Control-Request-Method' => 'GET', + 'Access-Control-Request-Headers' => 'content-type' + } + + expect(response.status).to eq(200) + expect(response.headers['Access-Control-Allow-Origin']).to eq('*') + expect(response.headers['Access-Control-Allow-Methods']).to eq('GET, OPTIONS') + expect(response.headers['Access-Control-Allow-Headers']).to eq('Origin, Content-Type, Accept, Authorization') + expect(response.headers['Access-Control-Max-Age']).to eq('3600') + expect(response.headers['Cross-Origin-Resource-Policy']).to eq('cross-origin') + end + end + + context 'GET request with Origin header' do + it 'includes CORS headers when Origin header is present' do + # Note: This test may fail if ethscription #1 doesn't exist, but the CORS headers should still be set + get "/ethscriptions/1/attachment", + headers: { 'Origin' => 'https://example.com' } + + # Check CORS headers are present regardless of whether the ethscription exists + expect(response.headers['Access-Control-Allow-Origin']).to eq('*') + expect(response.headers['Access-Control-Allow-Methods']).to eq('GET, OPTIONS') + expect(response.headers['Access-Control-Allow-Headers']).to eq('Origin, Content-Type, Accept, Authorization') + expect(response.headers['Cross-Origin-Resource-Policy']).to eq('cross-origin') + end + end + end + + describe 'Global CORS handling for other endpoints' do + context 'GET request to index endpoint with Origin header' do + it 'includes global CORS headers when Origin header is present' do + get "/ethscriptions", + headers: { 'Origin' => 'https://example.com' } + + # Check global CORS headers are present + expect(response.headers['Access-Control-Allow-Origin']).to eq('*') + expect(response.headers['Access-Control-Allow-Methods']).to eq('GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD') + expect(response.headers['Access-Control-Allow-Headers']).to eq('Origin, Content-Type, Accept, Authorization, X-Requested-With') + expect(response.headers['Cross-Origin-Resource-Policy']).to eq('cross-origin') + end + + it 'does not include CORS headers when Origin header is not present' do + get "/ethscriptions" + + # CORS headers should not be set when no Origin header is present + expect(response.headers['Access-Control-Allow-Origin']).to be_nil + end + end + end +end \ No newline at end of file