From 76b1fb1617569759575f38ccc5f7048a680582c4 Mon Sep 17 00:00:00 2001 From: Ollie Atkins Date: Tue, 3 Feb 2026 12:49:45 +0100 Subject: [PATCH 1/3] Rename beacons namespace to beacon Singular namespace better reflects that a beacon accesses its own resources rather than a collection. --- .../api/v1/{beacons => beacon}/base_controller.rb | 2 +- .../v1/{beacons => beacon}/statuses_controller.rb | 4 ++-- config/routes.rb | 2 +- spec/requests/api/v1/beacon_authentication_spec.rb | 12 ++++++------ 4 files changed, 10 insertions(+), 10 deletions(-) rename app/controllers/api/v1/{beacons => beacon}/base_controller.rb (88%) rename app/controllers/api/v1/{beacons => beacon}/statuses_controller.rb (77%) diff --git a/app/controllers/api/v1/beacons/base_controller.rb b/app/controllers/api/v1/beacon/base_controller.rb similarity index 88% rename from app/controllers/api/v1/beacons/base_controller.rb rename to app/controllers/api/v1/beacon/base_controller.rb index 1c0380a2..e74e5db9 100644 --- a/app/controllers/api/v1/beacons/base_controller.rb +++ b/app/controllers/api/v1/beacon/base_controller.rb @@ -1,6 +1,6 @@ module Api module V1 - module Beacons + module Beacon class BaseController < Api::V1::BaseController include Api::BeaconAuthentication end diff --git a/app/controllers/api/v1/beacons/statuses_controller.rb b/app/controllers/api/v1/beacon/statuses_controller.rb similarity index 77% rename from app/controllers/api/v1/beacons/statuses_controller.rb rename to app/controllers/api/v1/beacon/statuses_controller.rb index 1cc84ed1..c8c3b92b 100644 --- a/app/controllers/api/v1/beacons/statuses_controller.rb +++ b/app/controllers/api/v1/beacon/statuses_controller.rb @@ -1,7 +1,7 @@ module Api module V1 - module Beacons - class StatusesController < Beacons::BaseController + module Beacon + class StatusesController < Beacon::BaseController def show render json: { status: "ok", diff --git a/config/routes.rb b/config/routes.rb index bfa18754..0b4a058a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -40,7 +40,7 @@ namespace :v1 do resources :tags, only: %i[index show] - namespace :beacons do + namespace :beacon do resource :status, only: :show end end diff --git a/spec/requests/api/v1/beacon_authentication_spec.rb b/spec/requests/api/v1/beacon_authentication_spec.rb index f74e24a3..e6997cb1 100644 --- a/spec/requests/api/v1/beacon_authentication_spec.rb +++ b/spec/requests/api/v1/beacon_authentication_spec.rb @@ -1,11 +1,11 @@ require "rails_helper" RSpec.describe "Beacon Authentication", type: :request do - describe "GET /api/v1/beacons/status" do + describe "GET /api/v1/beacon/status" do it "returns beacon info with a valid API key" do beacon, raw_key = create_beacon_with_key(name: "Test Beacon") - get "/api/v1/beacons/status", headers: beacon_auth_headers(raw_key) + get "/api/v1/beacon/status", headers: beacon_auth_headers(raw_key) expect(response).to have_http_status(:ok) body = response.parsed_body @@ -15,14 +15,14 @@ end it "returns unauthorized when no authorization header is present" do - get "/api/v1/beacons/status" + get "/api/v1/beacon/status" expect(response).to have_http_status(:unauthorized) expect(response.parsed_body["error"]).to eq("Missing authorization header") end it "returns unauthorized with an invalid API key" do - get "/api/v1/beacons/status", headers: beacon_auth_headers("sk_live_invalid_key_000000000") + get "/api/v1/beacon/status", headers: beacon_auth_headers("sk_live_invalid_key_000000000") expect(response).to have_http_status(:unauthorized) expect(response.parsed_body["error"]).to eq("Invalid API key") @@ -32,14 +32,14 @@ beacon, raw_key = create_beacon_with_key beacon.revoke! - get "/api/v1/beacons/status", headers: beacon_auth_headers(raw_key) + get "/api/v1/beacon/status", headers: beacon_auth_headers(raw_key) expect(response).to have_http_status(:unauthorized) expect(response.parsed_body["error"]).to eq("API key has been revoked") end it "returns unauthorized with a malformed authorization header" do - get "/api/v1/beacons/status", headers: { "Authorization" => "Token sk_live_something" } + get "/api/v1/beacon/status", headers: { "Authorization" => "Token sk_live_something" } expect(response).to have_http_status(:unauthorized) expect(response.parsed_body["error"]).to eq("Missing authorization header") From 641bd42f07ece9f12775d6489ee76a057c13f610 Mon Sep 17 00:00:00 2001 From: Ollie Atkins Date: Tue, 3 Feb 2026 17:20:49 +0100 Subject: [PATCH 2/3] 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 authorization layer by joining through the attachments and beacon_topics associations to ensure beacons cannot access files from topics belonging to other beacons. Co-Authored-By: Claude Sonnet 4.5 --- app/models/beacon.rb | 10 ++++++++ spec/models/beacon_spec.rb | 52 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/app/models/beacon.rb b/app/models/beacon.rb index d613ae38..b81f16d4 100644 --- a/app/models/beacon.rb +++ b/app/models/beacon.rb @@ -48,4 +48,14 @@ def revoke! def revoked? revoked_at.present? end + + def accessible_blobs + ActiveStorage::Blob + .joins(:attachments) + .where(active_storage_attachments: { record_type: "Topic", name: "documents" }) + .joins("INNER JOIN topics ON topics.id = active_storage_attachments.record_id") + .joins("INNER JOIN beacon_topics ON beacon_topics.topic_id = topics.id") + .where(beacon_topics: { beacon_id: id }) + end end + diff --git a/spec/models/beacon_spec.rb b/spec/models/beacon_spec.rb index f397a629..12e66a37 100644 --- a/spec/models/beacon_spec.rb +++ b/spec/models/beacon_spec.rb @@ -87,4 +87,56 @@ expect(beacon).to be_revoked end end + + describe "#accessible_blobs" do + let(:beacon) { create(:beacon) } + let(:other_beacon) { create(:beacon) } + let(:topic_with_documents) { create(:topic, :with_documents) } + let(:other_topic_with_documents) { create(:topic, :with_documents) } + + before do + create(:beacon_topic, beacon: beacon, topic: topic_with_documents) + create(:beacon_topic, beacon: other_beacon, topic: other_topic_with_documents) + end + + it "returns blobs from topics associated with the beacon" do + document_blob = topic_with_documents.documents.first.blob + + expect(beacon.accessible_blobs).to include(document_blob) + end + + it "does not return blobs from topics associated with other beacons" do + other_document_blob = other_topic_with_documents.documents.first.blob + + expect(beacon.accessible_blobs).not_to include(other_document_blob) + end + + it "only returns document attachments" do + topic = create(:topic) + create(:beacon_topic, beacon: beacon, topic: topic) + + topic.documents.attach( + io: StringIO.new("test"), + filename: "document.pdf", + content_type: "application/pdf" + ) + + topic.files.attach( + io: StringIO.new("test"), + filename: "file.txt", + content_type: "text/plain" + ) + + document_blob = topic.documents.first.blob + file_blob = topic.files.first.blob + + expect(beacon.accessible_blobs).to include(document_blob) + expect(beacon.accessible_blobs).not_to include(file_blob) + end + + it "returns an ActiveRecord relation" do + expect(beacon.accessible_blobs).to be_a(ActiveRecord::Relation) + end + end end + From 5621b6e19f9344aee5f3f65cf30b978177244f2f Mon Sep 17 00:00:00 2001 From: Ollie Atkins Date: Tue, 3 Feb 2026 17:21:23 +0100 Subject: [PATCH 3/3] Add file download endpoint with Range support The endpoint allows beacons to download documents from their associated topics using the accessible_blobs scope for authorization. ActiveStorage streaming handles both full file downloads and partial content requests via Range headers for efficient delivery of large files. Co-Authored-By: Claude Sonnet 4.5 --- .../api/v1/beacon/files_controller.rb | 19 +++++ config/routes.rb | 1 + spec/requests/api/v1/beacon_files_spec.rb | 69 +++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 app/controllers/api/v1/beacon/files_controller.rb create mode 100644 spec/requests/api/v1/beacon_files_spec.rb diff --git a/app/controllers/api/v1/beacon/files_controller.rb b/app/controllers/api/v1/beacon/files_controller.rb new file mode 100644 index 00000000..c807f23e --- /dev/null +++ b/app/controllers/api/v1/beacon/files_controller.rb @@ -0,0 +1,19 @@ +module Api + module V1 + module Beacon + class FilesController < Beacon::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 + end + end + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 0b4a058a..c1a2fc9e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -42,6 +42,7 @@ namespace :beacon do resource :status, only: :show + resources :files, only: :show end end end diff --git a/spec/requests/api/v1/beacon_files_spec.rb b/spec/requests/api/v1/beacon_files_spec.rb new file mode 100644 index 00000000..9f4e7310 --- /dev/null +++ b/spec/requests/api/v1/beacon_files_spec.rb @@ -0,0 +1,69 @@ +require "rails_helper" + +RSpec.describe "Beacon Files", type: :request do + describe "GET /api/v1/beacon/files/:id" do + let(:beacon_with_key) { create_beacon_with_key(name: "Test Beacon") } + let(:beacon) { beacon_with_key.first } + let(:raw_key) { beacon_with_key.second } + let(:topic) { create(:topic, :with_documents) } + let(:document) { topic.documents.first } + let(:blob_id) { document.blob_id } + + before do + create(:beacon_topic, beacon: beacon, topic: topic) + end + + context "when downloading without Range header" do + it "returns the full file with attachment disposition" do + get "/api/v1/beacon/files/#{blob_id}", headers: beacon_auth_headers(raw_key) + + expect(response).to have_http_status(:success) + expect(response.headers["Content-Disposition"]).to include("attachment") + end + end + + context "when downloading with Range header" do + it "returns partial content with byte range" do + get "/api/v1/beacon/files/#{blob_id}", + headers: beacon_auth_headers(raw_key).merge("Range" => "bytes=0-99") + + expect(response).to have_http_status(:partial_content) + end + + it "handles multiple byte ranges" do + get "/api/v1/beacon/files/#{blob_id}", + headers: beacon_auth_headers(raw_key).merge("Range" => "bytes=0-99,200-299") + + expect(response).to have_http_status(:partial_content) + end + end + + context "when file does not exist" do + it "returns not found" do + get "/api/v1/beacon/files/99999", headers: beacon_auth_headers(raw_key) + + expect(response).to have_http_status(:not_found) + end + end + + context "when file belongs to a different beacon" do + let(:other_beacon_with_key) { create_beacon_with_key(name: "Other Beacon") } + let(:other_beacon) { other_beacon_with_key.first } + let(:other_raw_key) { other_beacon_with_key.second } + + it "returns not found" do + get "/api/v1/beacon/files/#{blob_id}", headers: beacon_auth_headers(other_raw_key) + + expect(response).to have_http_status(:not_found) + end + end + + context "when beacon is not authenticated" do + it "returns unauthorized" do + get "/api/v1/beacon/files/#{blob_id}" + + expect(response).to have_http_status(:unauthorized) + end + end + end +end