From 396d860503efc64558813d6f1391da87d797c9da Mon Sep 17 00:00:00 2001 From: Ollie Atkins Date: Wed, 4 Feb 2026 14:35:39 +0100 Subject: [PATCH 1/2] Add accessible_blobs method to Beacon model Beacons need to scope file access to only documents attached to their associated topics. The accessible_blobs method provides this authorisation layer by joining through the attachments and beacon_topics associations to ensure beacons cannot access files from topics belonging to other beacons. --- app/models/beacon.rb | 12 +++++++ spec/models/beacon_spec.rb | 68 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) 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/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 From 5aa9416ccb14852e8c1569b31a7f88b54f0fd64a Mon Sep 17 00:00:00 2001 From: Ollie Atkins Date: Wed, 4 Feb 2026 15:28:50 +0100 Subject: [PATCH 2/2] Add file download endpoint for beacon content sync Beacons receive a manifest listing files they need, then download each file individually via this endpoint. The controller validates that the requesting beacon has access through its assigned topics and supports resumable downloads via Range headers for reliability on unstable connections. --- .../api/v1/beacons/files_controller.rb | 21 +++ config/routes.rb | 2 + spec/requests/api/v1/beacons/files_spec.rb | 132 ++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 app/controllers/api/v1/beacons/files_controller.rb create mode 100644 spec/requests/api/v1/beacons/files_spec.rb 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/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/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