diff --git a/app/controllers/api/v1/beacons/files_controller.rb b/app/controllers/api/v1/beacons/files_controller.rb new file mode 100644 index 00000000..eac51745 --- /dev/null +++ b/app/controllers/api/v1/beacons/files_controller.rb @@ -0,0 +1,21 @@ +module Api + module V1 + module Beacons + class FilesController < Beacons::BaseController + include ActiveStorage::Streaming + + def show + blob = Current.beacon.accessible_blobs.find(params[:id]) + + if request.headers["Range"].present? + send_blob_byte_range_data(blob, request.headers["Range"]) + else + send_blob_stream(blob, disposition: :attachment) + end + rescue ActiveRecord::RecordNotFound + head :not_found + end + end + end + end +end diff --git a/app/models/beacon.rb b/app/models/beacon.rb index d613ae38..db509e4b 100644 --- a/app/models/beacon.rb +++ b/app/models/beacon.rb @@ -48,4 +48,16 @@ def revoke! def revoked? revoked_at.present? end + + def accessible_blobs + ActiveStorage::Blob + .joins(:attachments) + .where( + active_storage_attachments: { + record_type: "Topic", + name: "documents", + record_id: topic_ids, + } + ) + end end diff --git a/config/routes.rb b/config/routes.rb index bfa18754..aa3a0320 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -41,6 +41,8 @@ resources :tags, only: %i[index show] namespace :beacons do + resource :manifest, only: :show + resources :files, only: :show resource :status, only: :show end end diff --git a/spec/models/beacon_spec.rb b/spec/models/beacon_spec.rb index f397a629..8ee480f0 100644 --- a/spec/models/beacon_spec.rb +++ b/spec/models/beacon_spec.rb @@ -87,4 +87,72 @@ expect(beacon).to be_revoked end end + + describe "#accessible_blobs" do + let(:beacon) { create(:beacon) } + let(:provider) { create(:provider) } + let(:other_beacon) { create(:beacon) } + + before do + beacon.providers << provider + other_beacon.providers << provider + end + + context "when beacon has topics with documents" do + let!(:topic1) { create(:topic, :with_documents, provider: provider, language: beacon.language) } + let!(:topic2) { create(:topic, :with_documents, provider: provider, language: beacon.language) } + + before do + beacon.topics << topic1 + beacon.topics << topic2 + end + + it "returns blobs from all beacon topics" do + expected_blob_ids = (topic1.documents + topic2.documents).map(&:blob_id) + expect(beacon.accessible_blobs.pluck(:id)).to match_array(expected_blob_ids) + end + + it "returns ActiveStorage::Blob records" do + expect(beacon.accessible_blobs.first).to be_a(ActiveStorage::Blob) + end + end + + context "when beacon has no topics" do + it "returns empty relation" do + expect(beacon.accessible_blobs).to be_empty + end + end + + context "when topics have no documents" do + let!(:topic_without_docs) { create(:topic, provider: provider, language: beacon.language) } + + before do + beacon.topics << topic_without_docs + end + + it "returns empty relation" do + expect(beacon.accessible_blobs).to be_empty + end + end + + context "when other beacons have topics with documents" do + let!(:beacon_topic) { create(:topic, :with_documents, provider: provider, language: beacon.language) } + let!(:other_topic) { create(:topic, :with_documents, provider: provider, language: other_beacon.language) } + + before do + beacon.topics << beacon_topic + other_beacon.topics << other_topic + end + + it "only returns blobs from this beacon's topics" do + expected_blob_ids = beacon_topic.documents.map(&:blob_id) + expect(beacon.accessible_blobs.pluck(:id)).to match_array(expected_blob_ids) + end + + it "does not return blobs from other beacons' topics" do + other_blob_ids = other_topic.documents.map(&:blob_id) + expect(beacon.accessible_blobs.pluck(:id)).not_to include(*other_blob_ids) + end + end + end end diff --git a/spec/requests/api/v1/beacons/files_spec.rb b/spec/requests/api/v1/beacons/files_spec.rb new file mode 100644 index 00000000..fe894aa2 --- /dev/null +++ b/spec/requests/api/v1/beacons/files_spec.rb @@ -0,0 +1,132 @@ +require "rails_helper" + +RSpec.describe "Beacons Files API", type: :request do + let(:language) { create(:language) } + let(:region) { create(:region) } + let(:provider) { create(:provider) } + + let(:beacon) do + b, @raw_key = create_beacon_with_key(language: language, region: region) + b.providers << provider + b + end + + let(:raw_key) { beacon; @raw_key } + + let(:topic) do + create(:topic, :with_documents, provider: provider, language: language).tap do |t| + beacon.topics << t + end + end + + let(:blob) { topic.documents.first.blob } + + describe "GET /api/v1/beacons/files/:id" do + context "with valid authentication and access" do + it "returns the file with correct headers" do + get "/api/v1/beacons/files/#{blob.id}", headers: beacon_auth_headers(raw_key) + + expect(response).to have_http_status(:ok) + expect(response.headers["Content-Type"]).to eq(blob.content_type) + expect(response.headers["Content-Disposition"]).to include("attachment") + expect(response.headers["Content-Length"]).to eq(blob.byte_size.to_s) + end + + it "streams the file content" do + get "/api/v1/beacons/files/#{blob.id}", headers: beacon_auth_headers(raw_key) + + expect(response.body).not_to be_empty + expect(response.body.bytesize).to eq(blob.byte_size) + end + + it "includes the filename in Content-Disposition" do + get "/api/v1/beacons/files/#{blob.id}", headers: beacon_auth_headers(raw_key) + + expect(response.headers["Content-Disposition"]).to include(blob.filename.to_s) + end + end + + context "with Range header for resumable downloads" do + it "returns 206 Partial Content" do + get "/api/v1/beacons/files/#{blob.id}", + headers: beacon_auth_headers(raw_key).merge("Range" => "bytes=0-1023") + + expect(response).to have_http_status(:partial_content) + end + + it "includes Content-Range header" do + get "/api/v1/beacons/files/#{blob.id}", + headers: beacon_auth_headers(raw_key).merge("Range" => "bytes=0-1023") + + expect(response.headers["Content-Range"]).to be_present + expect(response.headers["Content-Range"]).to match(/bytes 0-1023\/\d+/) + end + + it "returns only the requested byte range" do + get "/api/v1/beacons/files/#{blob.id}", + headers: beacon_auth_headers(raw_key).merge("Range" => "bytes=0-99") + + expect(response.body.bytesize).to be <= 100 + end + end + + context "when file does not exist" do + it "returns 404" do + get "/api/v1/beacons/files/99999", headers: beacon_auth_headers(raw_key) + + expect(response).to have_http_status(:not_found) + end + end + + context "when beacon does not have access to the file" do + let(:other_beacon) do + b, @other_raw_key = create_beacon_with_key(language: language, region: region) + b.providers << provider + b + end + + let(:other_raw_key) { other_beacon; @other_raw_key } + + let(:other_topic) do + create(:topic, :with_documents, provider: provider, language: language).tap do |t| + other_beacon.topics << t + end + end + + let(:other_blob) { other_topic.documents.first.blob } + + it "returns 404 when requesting another beacon's file" do + get "/api/v1/beacons/files/#{other_blob.id}", headers: beacon_auth_headers(raw_key) + + expect(response).to have_http_status(:not_found) + end + end + + context "without authentication" do + it "returns 401" do + get "/api/v1/beacons/files/#{blob.id}" + + expect(response).to have_http_status(:unauthorized) + end + end + + context "with invalid authentication" do + it "returns 401" do + get "/api/v1/beacons/files/#{blob.id}", + headers: beacon_auth_headers("invalid-key") + + expect(response).to have_http_status(:unauthorized) + end + end + + context "with revoked beacon" do + before { beacon.revoke! } + + it "returns 401" do + get "/api/v1/beacons/files/#{blob.id}", headers: beacon_auth_headers(raw_key) + + expect(response).to have_http_status(:unauthorized) + end + end + end +end